Migrate the UserService/Model to not be a singleton

- Rename userService to userModel on appContext
- Merge user-model.ts and user-service.ts
- Rename UserService to UserModel
- Move all observables onto UserModel
- Inject UserModel in the models/services that were directly accessing
the observables

Google-Bug-Id: b/206459178, b/207628953
Change-Id: Icf4f81c877efb58e289678a7331bde7254142a58
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index f5bbf00..6673cdf 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -187,11 +187,11 @@
 export class MyCustomElement extends ...{
     constructor() {
         super(); //This is mandatory to call parent constructor
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
     }
     //...
     _getUserName() {
-        return this._userService.activeUserName();
+        return this._userModel.activeUserName();
     }
 }
 ```
@@ -203,12 +203,12 @@
 export class MyCustomElement extends ...{
     created() {
         // Incorrect: assign all dependencies in the constructor
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
     }
     //...
     _getUserName() {
         // Incorrect: use appContext outside of a constructor
-        return appContext.userService.activeUserName();
+        return appContext.userModel.activeUserName();
     }
 }
 ```
@@ -237,7 +237,7 @@
     constructor() {
         super();
         // Assign services here
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
         // Code from the created method - put it before existing actions in constructor
         createdAction1();
         createdAction2();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index f309171..1076990 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -50,7 +50,6 @@
 import {deepClone} from '../../../utils/deep-util';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
-import {preferences$} from '../../../services/user/user-model';
 import {subscribe} from '../../lit/subscription-controller';
 
 const STATES = {
@@ -122,11 +121,13 @@
 
   @state() private pluginConfigChanged = false;
 
+  private readonly userModel = getAppContext().userModel;
+
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    subscribe(this, preferences$, prefs => {
+    subscribe(this, this.userModel.preferences$, prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this.selectedScheme = prefs.download_scheme.toLowerCase();
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 0df3a3d..f59fdec 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -65,7 +65,6 @@
 import {modifierPressed} from '../../../utils/dom-util';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {account$} from '../../../services/user/user-model';
 import {
   changeComments$,
   threads$,
@@ -412,6 +411,8 @@
 
   private showAllChips = new Map<RunStatus | Category, boolean>();
 
+  private userModel = getAppContext().userModel;
+
   private checksService = getAppContext().checksService;
 
   constructor() {
@@ -428,7 +429,7 @@
     subscribe(this, topLevelActionsLatest$, x => (this.actions = x));
     subscribe(this, changeComments$, x => (this.changeComments = x));
     subscribe(this, threads$, x => (this.commentThreads = x));
-    subscribe(this, account$, x => (this.selfAccount = x));
+    subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
   }
 
   static override get styles() {
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 7d8bb86..8b530f6 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
@@ -202,7 +202,6 @@
   hasAttention,
 } from '../../../utils/attention-set-util';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {preferenceDiffViewMode$} from '../../../services/user/user-model';
 import {change$, changeLoading$} from '../../../services/change/change-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
@@ -562,7 +561,8 @@
 
   readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = getAppContext().userService;
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
 
   private readonly commentsService = getAppContext().commentsService;
 
@@ -648,7 +648,7 @@
       })
     );
     this.subscriptions.push(
-      preferenceDiffViewMode$.subscribe(diffViewMode => {
+      this.userModel.preferenceDiffViewMode$.subscribe(diffViewMode => {
         this.diffViewMode = diffViewMode;
       })
     );
@@ -790,9 +790,9 @@
 
   _handleToggleDiffMode() {
     if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userService.updatePreferences({
+      this.userModel.updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
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 6104356..67599ad 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
@@ -27,7 +27,6 @@
   MessageTag,
   PrimaryTab,
   createDefaultPreferences,
-  createDefaultDiffPrefs,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -107,7 +106,6 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {_testOnly_setState as setUserState} from '../../../services/user/user-model';
 import {_testOnly_setState as setChangeState} from '../../../services/change/change-model';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -818,10 +816,7 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      setUserState({
-        preferences: prefs,
-        diffPreferences: createDefaultDiffPrefs(),
-      });
+      element.userModel.setPreferences(prefs);
       element._handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
@@ -831,10 +826,7 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.UNIFIED,
       };
-      setUserState({
-        preferences: newPrefs,
-        diffPreferences: createDefaultDiffPrefs(),
-      });
+      element.userModel.setPreferences(newPrefs);
       await flush();
       element._handleToggleDiffMode();
       assert.isTrue(
@@ -1745,6 +1737,7 @@
         '#replyDialog'
       );
       const openSpy = sinon.spy(dialog, 'open');
+      await flush();
       await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
       assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
     });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index f400814..f286098 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -83,12 +83,9 @@
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {
-  diffPreferences$,
-  sizeBarInChangeTable$,
-} from '../../../services/user/user-model';
 import {changeComments$} from '../../../services/comments/comments-model';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {select} from '../../../utils/observable-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -317,7 +314,7 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = getAppContext().userService;
+  private readonly userModel = getAppContext().userModel;
 
   private readonly browserModel = getAppContext().browserModel;
 
@@ -377,26 +374,23 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.subscriptions.push(
+    this.subscriptions = [
       changeComments$.subscribe(changeComments => {
         this.changeComments = changeComments;
-      })
-    );
-    this.subscriptions.push(
+      }),
       this.browserModel.diffViewMode$.subscribe(
         diffView => (this.diffViewMode = diffView)
-      )
-    );
-    this.subscriptions.push(
-      diffPreferences$.subscribe(diffPreferences => {
+      ),
+      this.userModel.diffPreferences$.subscribe(diffPreferences => {
         this.diffPrefs = diffPreferences;
-      })
-    );
-    this.subscriptions.push(
-      sizeBarInChangeTable$.subscribe(sizeBarInChangeTable => {
+      }),
+      select(
+        this.userModel.preferences$,
+        prefs => !!prefs?.size_bar_in_change_table
+      ).subscribe(sizeBarInChangeTable => {
         this._showSizeBars = sizeBarInChangeTable;
-      })
-    );
+      }),
+    ];
 
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -1648,7 +1642,7 @@
   }
 
   _handleReloadingDiffPreference() {
-    this.userService.getDiffPreferences();
+    this.userModel.getDiffPreferences();
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index bd51aab..cedddec 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -57,7 +57,6 @@
   changeNum$,
   repo$,
 } from '../../../services/change/change-model';
-import {loggedIn$} from '../../../services/user/user-model';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -263,6 +262,8 @@
   @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
   _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
 
+  private readonly userModel = getAppContext().userModel;
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly shortcuts = getAppContext().shortcutsService;
@@ -282,7 +283,7 @@
       })
     );
     this.subscriptions.push(
-      loggedIn$.subscribe(x => {
+      this.userModel.loggedIn$.subscribe(x => {
         this.showReplyButtons = x;
       })
     );
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 29c8eca..1391257 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icons/gr-icons';
@@ -37,7 +38,6 @@
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {getAppContext} from '../../../services/app-context';
 import {serverConfig$} from '../../../services/config/config-model';
-import {myTopMenuItems$} from '../../../services/user/user-model';
 import {assertIsDefined} from '../../../utils/common-util';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
@@ -158,7 +158,7 @@
 
   private readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly userService = getAppContext().userService;
+  private readonly userModel = getAppContext().userModel;
 
   private subscriptions: Subscription[] = [];
 
@@ -168,18 +168,23 @@
   }
 
   override connectedCallback() {
-    // TODO(brohlfs): This just ensures that the userService is instantiated at
+    // TODO(brohlfs): This just ensures that the userModel is instantiated at
     // all. We need the service to manage the model, but we are not making any
     // direct calls. Will need to find a better solution to this problem ...
-    assertIsDefined(this.userService);
+    assertIsDefined(this.userModel);
 
     super.connectedCallback();
     this._loadAccount();
 
     this.subscriptions.push(
-      myTopMenuItems$.subscribe(items => {
-        this._userLinks = items.map(this._createHeaderLink);
-      })
+      this.userModel.preferences$
+        .pipe(
+          map(preferences => preferences?.my ?? []),
+          distinctUntilChanged()
+        )
+        .subscribe(items => {
+          this._userLinks = items.map(this._createHeaderLink);
+        })
     );
     this.subscriptions.push(
       serverConfig$.subscribe(config => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 0d63360..fd30c6a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -50,7 +50,7 @@
   // Private but accessed by tests.
   readonly browserModel = getAppContext().browserModel;
 
-  private readonly userService = getAppContext().userService;
+  private readonly userModel = getAppContext().userModel;
 
   private subscriptions: Subscription[] = [];
 
@@ -83,7 +83,7 @@
    */
   setMode(newMode: DiffViewMode) {
     if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.userService.updatePreferences({diff_view: newMode});
+      this.userModel.updatePreferences({diff_view: newMode});
     }
     this.mode = newMode;
     let announcement;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index 7f7f265..f469799 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -19,7 +19,6 @@
 import './gr-diff-preferences-dialog';
 import {GrDiffPreferencesDialog} from './gr-diff-preferences-dialog';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {updateDiffPreferences} from '../../../services/user/user-model';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-diff-preferences-dialog');
@@ -37,7 +36,6 @@
       line_wrapping: true,
     };
     element.diffPrefs = originalDiffPrefs;
-    updateDiffPreferences(originalDiffPrefs);
     await flush();
     element.open();
     await flush();
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 c4e2488..ca89b1d 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
@@ -118,10 +118,6 @@
 import {Subscription, combineLatest} from 'rxjs';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {
-  preferences$,
-  diffPreferences$,
-} from '../../../services/user/user-model';
-import {
   diffPath$,
   currentPatchNum$,
   change$,
@@ -359,11 +355,12 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = getAppContext().userService;
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
 
   private readonly changeService = getAppContext().changeService;
 
-  // Private but used in tests
+  // Private but used in tests.
   readonly browserModel = getAppContext().browserModel;
 
   // We just want to make sure that CommentsService is instantiated.
@@ -401,12 +398,12 @@
     );
 
     this.subscriptions.push(
-      preferences$.subscribe(preferences => {
+      this.userModel.preferences$.subscribe(preferences => {
         this._userPrefs = preferences;
       })
     );
     this.subscriptions.push(
-      diffPreferences$.subscribe(diffPreferences => {
+      this.userModel.diffPreferences$.subscribe(diffPreferences => {
         this._prefs = diffPreferences;
       })
     );
@@ -423,7 +420,12 @@
     // properties since the method will be called anytime a property updates
     // but we only want to call this on the initial load.
     this.subscriptions.push(
-      combineLatest(currentPatchNum$, routerView$, diffPath$, diffPreferences$)
+      combineLatest(
+        currentPatchNum$,
+        routerView$,
+        diffPath$,
+        this.userModel.diffPreferences$
+      )
         .pipe(
           filter(
             ([currentPatchNum, routerView, path, diffPrefs]) =>
@@ -784,9 +786,9 @@
   _handleToggleDiffMode() {
     if (!this._userPrefs) return;
     if (this._userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
-      this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userService.updatePreferences({
+      this.userModel.updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
@@ -1764,7 +1766,7 @@
   }
 
   _handleReloadingDiffPreference() {
-    this.userService.getDiffPreferences();
+    this.userModel.getDiffPreferences();
   }
 
   _computeCanEdit(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 2367342..9076420 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -31,7 +31,6 @@
 import {EditPatchSetNum} from '../../../types/common.js';
 import {CursorMoveResult} from '../../../api/core.js';
 import {Side} from '../../../api/diff.js';
-import {_testOnly_setState as setUserModelState, _testOnly_getState as getUserModelState} from '../../../services/user/user-model.js';
 import {_testOnly_setState as setChangeModelState} from '../../../services/change/change-model.js';
 import {_testOnly_setState as setCommentState} from '../../../services/comments/comments-model.js';
 
@@ -1199,7 +1198,7 @@
         ...createDefaultDiffPrefs(),
         manual_review: true,
       };
-      setUserModelState({...getUserModelState(), diffPreferences});
+      element.userModel.setDiffPreferences(diffPreferences);
       setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
 
       setRouterModelState({
@@ -1216,8 +1215,7 @@
       assert.isTrue(getReviewedStub.called);
 
       // if prefs are updated then the reviewed status should not be set again
-      setUserModelState({...getUserModelState(),
-        diffPreferences: createDefaultDiffPrefs()});
+      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
 
       await flush();
       assert.isFalse(saveReviewedStub.called);
@@ -1237,7 +1235,7 @@
             ...createDefaultDiffPrefs(),
             manual_review: false,
           };
-          setUserModelState({...getUserModelState(), diffPreferences});
+          element.userModel.setDiffPreferences(diffPreferences);
           setChangeModelState({change: createChange(),
             diffPath: '/COMMIT_MSG'});
 
@@ -1262,8 +1260,7 @@
           .callsFake(() => Promise.resolve());
       sinon.stub(element.$.diffHost, 'reload');
 
-      setUserModelState({...getUserModelState(),
-        diffPreferences: createDefaultDiffPrefs()});
+      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
       setChangeModelState({change: createChange(), diffPath: '/COMMIT_MSG'});
 
       setRouterModelState({
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 4657020..23d9693 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -25,7 +25,6 @@
 import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
 import {GrSelect} from '../gr-select/gr-select';
 import {getAppContext} from '../../../services/app-context';
-import {diffPreferences$} from '../../../services/user/user-model';
 
 export interface GrDiffPreferences {
   $: {
@@ -56,14 +55,14 @@
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
 
-  private readonly userService = getAppContext().userService;
+  private readonly userModel = getAppContext().userModel;
 
   private subscriptions: Subscription[] = [];
 
   override connectedCallback() {
     super.connectedCallback();
     this.subscriptions.push(
-      diffPreferences$.subscribe(diffPreferences => {
+      this.userModel.diffPreferences$.subscribe(diffPreferences => {
         this.diffPrefs = diffPreferences;
       })
     );
@@ -142,7 +141,7 @@
 
   async save() {
     if (!this.diffPrefs) return;
-    await this.userService.updateDiffPreference(this.diffPrefs);
+    await this.userModel.updateDiffPreference(this.diffPrefs);
     this.hasUnsavedChanges = false;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 68d8d7d..6eb19da 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -27,7 +27,6 @@
 import {getAppContext} from '../../../services/app-context';
 import {queryAndAssert} from '../../../utils/common-util';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
-import {preferences$} from '../../../services/user/user-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -73,7 +72,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = getAppContext().userService;
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
 
   private subscriptions: Subscription[] = [];
 
@@ -83,7 +83,7 @@
       this._loggedIn = loggedIn;
     });
     this.subscriptions.push(
-      preferences$.subscribe(prefs => {
+      this.userModel.preferences$.subscribe(prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
           this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -113,7 +113,7 @@
     if (scheme && scheme !== this.selectedScheme) {
       this.set('selectedScheme', scheme);
       if (this._loggedIn) {
-        this.userService.updatePreferences({
+        this.userModel.updatePreferences({
           download_scheme: this.selectedScheme,
         });
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 6cbef79..bd0ca70 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -19,7 +19,6 @@
 import './gr-download-commands';
 import {GrDownloadCommands} from './gr-download-commands';
 import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
-import {updatePreferences} from '../../../services/user/user-model';
 import {createPreferences} from '../../../test/test-data-generators';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
@@ -116,7 +115,7 @@
     test('loads scheme from preferences', async () => {
       const element = basicFixture.instantiate();
       await flush();
-      updatePreferences({
+      element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'repo',
       });
@@ -126,7 +125,7 @@
     test('normalize scheme from preferences', async () => {
       const element = basicFixture.instantiate();
       await flush();
-      updatePreferences({
+      element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'REPO',
       });
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 47d5f03..001b71d 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -26,7 +26,7 @@
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {GrStorageService} from './storage/gr-storage_impl';
 import {ConfigService} from './config/config-service';
-import {UserService} from './user/user-service';
+import {UserModel} from './user/user-model';
 import {CommentsService} from './comments/comments-service';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
@@ -74,15 +74,19 @@
       assertIsDefined(ctx.restApiService, 'restApiService');
       return new ConfigService(ctx.restApiService!);
     },
-    userService: (ctx: Partial<AppContext>) => {
+    userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserService(ctx.restApiService!);
+      return new UserModel(ctx.restApiService!);
     },
     shortcutsService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.userModel, 'userModel');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ShortcutsService(ctx.reportingService!);
+      return new ShortcutsService(ctx.userModel, ctx.reportingService!);
     },
-    browserModel: (_ctx: Partial<AppContext>) => new BrowserModel(),
+    browserModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.userModel, 'userModel');
+      return new BrowserModel(ctx.userModel!);
+    },
   };
   return create<AppContext>(appRegistry);
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 87a18ef..d5e595d 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -25,7 +25,7 @@
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 import {StorageService} from './storage/gr-storage';
 import {ConfigService} from './config/config-service';
-import {UserService} from './user/user-service';
+import {UserModel} from './user/user-model';
 import {CommentsService} from './comments/comments-service';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
 import {BrowserModel} from './browser/browser-model';
@@ -42,7 +42,7 @@
   jsApiService: JsApiService;
   storageService: StorageService;
   configService: ConfigService;
-  userService: UserService;
+  userModel: UserModel;
   browserModel: BrowserModel;
   shortcutsService: ShortcutsService;
 }
diff --git a/polygerrit-ui/app/services/browser/browser-model.ts b/polygerrit-ui/app/services/browser/browser-model.ts
index b15091a..a675cdd 100644
--- a/polygerrit-ui/app/services/browser/browser-model.ts
+++ b/polygerrit-ui/app/services/browser/browser-model.ts
@@ -17,8 +17,8 @@
 import {BehaviorSubject, Observable, combineLatest} from 'rxjs';
 import {distinctUntilChanged, map} from 'rxjs/operators';
 import {Finalizable} from '../registry';
-import {preferenceDiffViewMode$} from '../user/user-model';
 import {DiffViewMode} from '../../api/diff';
+import {UserModel} from '../user/user-model';
 
 // This value is somewhat arbitrary and not based on research or calculations.
 const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
@@ -42,7 +42,7 @@
     return this.privateState$;
   }
 
-  constructor() {
+  constructor(readonly userModel: UserModel) {
     const screenWidth$ = this.privateState$.pipe(
       map(
         state =>
@@ -55,7 +55,7 @@
     // the user model.
     this.diffViewMode$ = combineLatest([
       screenWidth$,
-      preferenceDiffViewMode$,
+      userModel.preferenceDiffViewMode$,
     ]).pipe(
       map(([isScreenTooSmall, preferenceDiffViewMode]) => {
         if (isScreenTooSmall) return DiffViewMode.UNIFIED;
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index 1f9e083..8f14077 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -15,13 +15,13 @@
  * limitations under the License.
  */
 import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
 import {
   config,
   Shortcut,
   ShortcutHelpItem,
   ShortcutSection,
 } from './shortcuts-config';
-import {disableShortcuts$} from '../user/user-model';
 import {
   ComboKey,
   eventMatchesShortcut,
@@ -33,6 +33,7 @@
 } from '../../utils/dom-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
 import {Finalizable} from '../registry';
+import {UserModel} from '../user/user-model';
 
 export type SectionView = Array<{binding: string[][]; text: string}>;
 
@@ -98,7 +99,10 @@
 
   private readonly subscriptions: Subscription[] = [];
 
-  constructor(readonly reporting?: ReportingService) {
+  constructor(
+    readonly userModel: UserModel,
+    readonly reporting?: ReportingService
+  ) {
     for (const section of config.keys()) {
       const items = config.get(section) ?? [];
       for (const item of items) {
@@ -106,7 +110,12 @@
       }
     }
     this.subscriptions.push(
-      disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x))
+      this.userModel.preferences$
+        .pipe(
+          map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
+          distinctUntilChanged()
+        )
+        .subscribe(x => (this.shortcutsDisabled = x))
     );
     this.keydownListener = (e: KeyboardEvent) => {
       if (!isComboKey(e.key)) return;
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index a024159..274cb87 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -24,6 +24,7 @@
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonFakeTimers} from 'sinon';
 import {Key, Modifier} from '../../utils/dom-util';
+import {getAppContext} from '../app-context';
 
 async function keyEventOn(
   el: HTMLElement,
@@ -45,7 +46,10 @@
   let service: ShortcutsService;
 
   setup(() => {
-    service = new ShortcutsService();
+    service = new ShortcutsService(
+      getAppContext().userModel,
+      getAppContext().reportingService
+    );
   });
 
   suite('shouldSuppress', () => {
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index f772ebe..0b6ef6b 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -14,16 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
+import {from, of, BehaviorSubject, Observable, Subscription} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {
+  DiffPreferencesInfo as DiffPreferencesInfoAPI,
+  DiffViewMode,
+} from '../../api/diff';
 import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
 import {
   createDefaultPreferences,
   createDefaultDiffPrefs,
 } from '../../constants/constants';
-import {DiffPreferencesInfo, DiffViewMode} from '../../api/diff';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {DiffPreferencesInfo} from '../../types/diff';
+import {Finalizable} from '../registry';
+import {select} from '../../utils/observable-util';
 
-interface UserState {
+export interface UserState {
   /**
    * Keeps being defined even when credentials have expired.
    */
@@ -32,83 +40,122 @@
   diffPreferences: DiffPreferencesInfo;
 }
 
-const initialState: UserState = {
-  preferences: createDefaultPreferences(),
-  diffPreferences: createDefaultDiffPrefs(),
-};
+export class UserModel implements Finalizable {
+  private readonly privateState$: BehaviorSubject<UserState> =
+    new BehaviorSubject({
+      preferences: createDefaultPreferences(),
+      diffPreferences: createDefaultDiffPrefs(),
+    });
 
-const privateState$ = new BehaviorSubject(initialState);
+  readonly account$: Observable<AccountDetailInfo | undefined> = select(
+    this.privateState$,
+    userState => userState.account
+  );
 
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
+  /** Note that this may still be true, even if credentials have expired. */
+  readonly loggedIn$: Observable<boolean> = select(
+    this.account$,
+    account => !!account
+  );
+
+  readonly preferences$: Observable<PreferencesInfo> = select(
+    this.privateState$,
+    userState => userState.preferences
+  );
+
+  readonly diffPreferences$: Observable<DiffPreferencesInfo> = select(
+    this.privateState$,
+    userState => userState.diffPreferences
+  );
+
+  readonly preferenceDiffViewMode$: Observable<DiffViewMode> = select(
+    this.preferences$,
+    preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE
+  );
+
+  private readonly subscriptions: Subscription[] = [];
+
+  get userState$(): Observable<UserState> {
+    return this.privateState$;
+  }
+
+  constructor(readonly restApiService: RestApiService) {
+    this.subscriptions = [
+      from(this.restApiService.getAccount()).subscribe(
+        (account?: AccountDetailInfo) => {
+          this.setAccount(account);
+        }
+      ),
+      this.account$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultPreferences());
+            return from(this.restApiService.getPreferences());
+          })
+        )
+        .subscribe((preferences?: PreferencesInfo) => {
+          this.setPreferences(preferences ?? createDefaultPreferences());
+        }),
+      this.account$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultDiffPrefs());
+            return from(this.restApiService.getDiffPreferences());
+          })
+        )
+        .subscribe((diffPrefs?: DiffPreferencesInfoAPI) => {
+          this.setDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
+        }),
+    ];
+  }
+
+  finalize() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions.splice(0, this.subscriptions.length);
+  }
+
+  updatePreferences(prefs: Partial<PreferencesInfo>) {
+    this.restApiService
+      .savePreferences(prefs)
+      .then((newPrefs: PreferencesInfo | undefined) => {
+        if (!newPrefs) return;
+        this.setPreferences(newPrefs);
+      });
+  }
+
+  updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
+    return this.restApiService
+      .saveDiffPreferences(diffPrefs)
+      .then((response: Response) => {
+        this.restApiService.getResponseObject(response).then(obj => {
+          const newPrefs = obj as unknown as DiffPreferencesInfo;
+          if (!newPrefs) return;
+          this.setDiffPreferences(newPrefs);
+        });
+      });
+  }
+
+  getDiffPreferences() {
+    return this.restApiService.getDiffPreferences().then(prefs => {
+      if (!prefs) return;
+      this.setDiffPreferences(prefs);
+    });
+  }
+
+  setPreferences(preferences: PreferencesInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, preferences});
+  }
+
+  setDiffPreferences(diffPreferences: DiffPreferencesInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, diffPreferences});
+  }
+
+  private setAccount(account?: AccountDetailInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, account});
+  }
 }
-
-export function _testOnly_setState(state: UserState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const userState$: Observable<UserState> = privateState$;
-
-export function updateAccount(account?: AccountDetailInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, account});
-}
-
-export function updatePreferences(preferences: PreferencesInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, preferences});
-}
-
-export function updateDiffPreferences(diffPreferences: DiffPreferencesInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, diffPreferences});
-}
-
-export const account$ = userState$.pipe(
-  map(userState => userState.account),
-  distinctUntilChanged()
-);
-
-/** Note that this may still be true, even if credentials have expired. */
-export const loggedIn$ = account$.pipe(
-  map(account => !!account),
-  distinctUntilChanged()
-);
-
-export const preferences$ = userState$.pipe(
-  map(userState => userState.preferences),
-  distinctUntilChanged()
-);
-
-export const diffPreferences$ = userState$.pipe(
-  map(userState => userState.diffPreferences),
-  distinctUntilChanged()
-);
-
-export const preferenceDiffViewMode$ = preferences$.pipe(
-  map(preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE),
-  distinctUntilChanged()
-);
-
-export const myTopMenuItems$ = preferences$.pipe(
-  map(preferences => preferences?.my ?? []),
-  distinctUntilChanged()
-);
-
-export const sizeBarInChangeTable$ = preferences$.pipe(
-  map(prefs => !!prefs?.size_bar_in_change_table),
-  distinctUntilChanged()
-);
-
-export const disableShortcuts$ = preferences$.pipe(
-  map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
deleted file mode 100644
index d2bca85..0000000
--- a/polygerrit-ui/app/services/user/user-service.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {from, of, Subscription} from 'rxjs';
-import {switchMap} from 'rxjs/operators';
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {
-  account$,
-  updateAccount,
-  updatePreferences,
-  updateDiffPreferences,
-} from './user-model';
-import {
-  createDefaultPreferences,
-  createDefaultDiffPrefs,
-} from '../../constants/constants';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {DiffPreferencesInfo} from '../../types/diff';
-import {Finalizable} from '../registry';
-
-export class UserService implements Finalizable {
-  private readonly subscriptions: Subscription[] = [];
-
-  constructor(readonly restApiService: RestApiService) {
-    from(this.restApiService.getAccount()).subscribe(
-      (account?: AccountDetailInfo) => {
-        updateAccount(account);
-      }
-    );
-    this.subscriptions.push(
-      account$
-        .pipe(
-          switchMap(account => {
-            if (!account) return of(createDefaultPreferences());
-            return from(this.restApiService.getPreferences());
-          })
-        )
-        .subscribe((preferences?: PreferencesInfo) => {
-          updatePreferences(preferences ?? createDefaultPreferences());
-        })
-    );
-    this.subscriptions.push(
-      account$
-        .pipe(
-          switchMap(account => {
-            if (!account) return of(createDefaultDiffPrefs());
-            return from(this.restApiService.getDiffPreferences());
-          })
-        )
-        .subscribe((diffPrefs?: DiffPreferencesInfo) => {
-          updateDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
-        })
-    );
-  }
-
-  finalize() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions.splice(0, this.subscriptions.length);
-  }
-
-  updatePreferences(prefs: Partial<PreferencesInfo>) {
-    this.restApiService
-      .savePreferences(prefs)
-      .then((newPrefs: PreferencesInfo | undefined) => {
-        if (!newPrefs) return;
-        updatePreferences(newPrefs);
-      });
-  }
-
-  updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
-    return this.restApiService
-      .saveDiffPreferences(diffPrefs)
-      .then((response: Response) => {
-        this.restApiService.getResponseObject(response).then(obj => {
-          const newPrefs = obj as unknown as DiffPreferencesInfo;
-          if (!newPrefs) return;
-          updateDiffPreferences(newPrefs);
-        });
-      });
-  }
-
-  getDiffPreferences() {
-    return this.restApiService.getDiffPreferences().then(prefs => {
-      if (!prefs) return;
-      updateDiffPreferences(prefs);
-    });
-  }
-}
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index f8fd534..f041354 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -46,14 +46,12 @@
 } from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
-import {updatePreferences} from '../services/user/user-model';
-import {createDefaultPreferences} from '../constants/constants';
+
 import {getAppContext} from '../services/app-context';
 import {_testOnly_resetState as resetChangeState} from '../services/change/change-model';
 import {_testOnly_resetState as resetChecksState} from '../services/checks/checks-model';
 import {_testOnly_resetState as resetCommentsState} from '../services/comments/comments-model';
 import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
-import {_testOnly_resetState as resetUserState} from '../services/user/user-model';
 
 declare global {
   interface Window {
@@ -122,7 +120,6 @@
   resetChecksState();
   resetCommentsState();
   resetRouterState();
-  resetUserState();
 
   const shortcuts = getAppContext().shortcutsService;
   assert.isTrue(shortcuts._testOnly_isEmpty());
@@ -220,7 +217,6 @@
   cancelAllTasks();
   cleanUpStorage();
   // Reset state
-  updatePreferences(createDefaultPreferences());
   _testOnlyFinalizeAppContext();
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 03c5967..a710b00 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -29,7 +29,7 @@
 import {ChecksService} from '../services/checks/checks-service';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {ConfigService} from '../services/config/config-service';
-import {UserService} from '../services/user/user-service';
+import {UserModel} from '../services/user/user-model';
 import {CommentsService} from '../services/comments/comments-service';
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {BrowserModel} from '../services/browser/browser-model';
@@ -68,15 +68,19 @@
       assertIsDefined(ctx.restApiService, 'restApiService');
       return new ConfigService(ctx.restApiService!);
     },
-    userService: (ctx: Partial<AppContext>) => {
+    userModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new UserService(ctx.restApiService!);
+      return new UserModel(ctx.restApiService!);
     },
     shortcutsService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.userModel, 'userModel');
       assertIsDefined(ctx.reportingService, 'reportingService');
-      return new ShortcutsService(ctx.reportingService!);
+      return new ShortcutsService(ctx.userModel!, ctx.reportingService!);
     },
-    browserModel: (_ctx: Partial<AppContext>) => new BrowserModel(),
+    browserModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.userModel, 'userModel');
+      return new BrowserModel(ctx.userModel!);
+    },
   };
   appContext = create<AppContext>(appRegistry);
   injectAppContext(appContext);
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 2b57e98..f2da972 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -24,7 +24,7 @@
 import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
 import {CommentsService} from '../services/comments/comments-service';
-import {UserService} from '../services/user/user-service';
+import {UserModel} from '../services/user/user-model';
 import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {queryAndAssert, query} from '../utils/common-util';
 import {FlagsService} from '../services/flags/flags';
@@ -116,8 +116,8 @@
   return sinon.stub(getAppContext().commentsService, method);
 }
 
-export function stubUsers<K extends keyof UserService>(method: K) {
-  return sinon.stub(getAppContext().userService, method);
+export function stubUsers<K extends keyof UserModel>(method: K) {
+  return sinon.stub(getAppContext().userModel, method);
 }
 
 export function stubShortcuts<K extends keyof ShortcutsService>(method: K) {
diff --git a/polygerrit-ui/app/utils/observable-util.ts b/polygerrit-ui/app/utils/observable-util.ts
new file mode 100644
index 0000000..e39aa48
--- /dev/null
+++ b/polygerrit-ui/app/utils/observable-util.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {Observable} from 'rxjs';
+import {distinctUntilChanged, map, shareReplay} from 'rxjs/operators';
+import {deepEqual} from './deep-util';
+
+export function select<A, B>(obs$: Observable<A>, mapper: (_: A) => B) {
+  return obs$.pipe(
+    map(mapper),
+    distinctUntilChanged(deepEqual),
+    shareReplay(1)
+  );
+}