From 2ddf1c34b2dec63cb62805d9c00a0e0d174a6c01 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Fri, 3 Oct 2025 09:01:49 +0200 Subject: [PATCH] [PM-25488] Badge stays after lock when using pin (#16436) * wip * feat: add dynamic states * feat: re-implement badge service with dynamic state functions * feat: completely remove old static states * feat: debounce calls to badge api per tab * feat: use group-by to avoid re-setting all tabs on 1 tab change * feat: simplify autofill badge updater * feat: add hanging function test * chore: clean up badge service * feat: simplify private updateBadge * feat: remove unnecessary Set usage * fix: tests that broke after setState rename * chore: clean up badge api --- .../auth-status-badge-updater.service.ts | 46 +- .../autofill-badge-updater.service.ts | 81 +- .../browser/src/background/main.background.ts | 2 - .../src/platform/badge/badge-browser-api.ts | 123 +- .../src/platform/badge/badge.service.spec.ts | 1266 ++++++++++------- .../src/platform/badge/badge.service.ts | 213 ++- apps/browser/src/platform/badge/scope.ts | 23 + apps/browser/src/platform/badge/state.ts | 4 +- .../badge/test/mock-badge-browser-api.ts | 61 +- ...-risk-cipher-badge-updater.service.spec.ts | 66 +- .../at-risk-cipher-badge-updater.service.ts | 156 +- libs/state/src/core/state-definitions.ts | 3 - 12 files changed, 1109 insertions(+), 935 deletions(-) create mode 100644 apps/browser/src/platform/badge/scope.ts diff --git a/apps/browser/src/auth/services/auth-status-badge-updater.service.ts b/apps/browser/src/auth/services/auth-status-badge-updater.service.ts index 4205ebc665d..4f239e54939 100644 --- a/apps/browser/src/auth/services/auth-status-badge-updater.service.ts +++ b/apps/browser/src/auth/services/auth-status-badge-updater.service.ts @@ -17,8 +17,8 @@ export class AuthStatusBadgeUpdaterService { private accountService: AccountService, private authService: AuthService, ) { - this.accountService.activeAccount$ - .pipe( + this.badgeService.setState(StateName, (_tab) => + this.accountService.activeAccount$.pipe( switchMap((account) => account ? this.authService.authStatusFor$(account.id) @@ -27,30 +27,36 @@ export class AuthStatusBadgeUpdaterService { mergeMap(async (authStatus) => { switch (authStatus) { case AuthenticationStatus.LoggedOut: { - await this.badgeService.setState(StateName, BadgeStatePriority.High, { - icon: BadgeIcon.LoggedOut, - backgroundColor: Unset, - text: Unset, - }); - break; + return { + priority: BadgeStatePriority.High, + state: { + icon: BadgeIcon.LoggedOut, + backgroundColor: Unset, + text: Unset, + }, + }; } case AuthenticationStatus.Locked: { - await this.badgeService.setState(StateName, BadgeStatePriority.High, { - icon: BadgeIcon.Locked, - backgroundColor: Unset, - text: Unset, - }); - break; + return { + priority: BadgeStatePriority.High, + state: { + icon: BadgeIcon.Locked, + backgroundColor: Unset, + text: Unset, + }, + }; } case AuthenticationStatus.Unlocked: { - await this.badgeService.setState(StateName, BadgeStatePriority.Low, { - icon: BadgeIcon.Unlocked, - }); - break; + return { + priority: BadgeStatePriority.Low, + state: { + icon: BadgeIcon.Unlocked, + }, + }; } } }), - ) - .subscribe(); + ), + ); } } diff --git a/apps/browser/src/autofill/services/autofill-badge-updater.service.ts b/apps/browser/src/autofill/services/autofill-badge-updater.service.ts index 06ddf16c8af..382c9efa7f8 100644 --- a/apps/browser/src/autofill/services/autofill-badge-updater.service.ts +++ b/apps/browser/src/autofill/services/autofill-badge-updater.service.ts @@ -1,4 +1,4 @@ -import { combineLatest, distinctUntilChanged, mergeMap, of, switchMap, withLatestFrom } from "rxjs"; +import { combineLatest, delay, distinctUntilChanged, mergeMap, of, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; @@ -10,7 +10,7 @@ import { Tab } from "../../platform/badge/badge-browser-api"; import { BadgeService } from "../../platform/badge/badge.service"; import { BadgeStatePriority } from "../../platform/badge/priority"; -const StateName = (tabId: number) => `autofill-badge-${tabId}`; +const StateName = "autofill-badge-updater"; export class AutofillBadgeUpdaterService { constructor( @@ -26,56 +26,30 @@ export class AutofillBadgeUpdaterService { switchMap((account) => (account?.id ? this.cipherService.ciphers$(account?.id) : of([]))), ); - // Recalculate badges for all active tabs when ciphers or active account changes - combineLatest({ - account: this.accountService.activeAccount$, - enableBadgeCounter: - this.badgeSettingsService.enableBadgeCounter$.pipe(distinctUntilChanged()), - ciphers: ciphers$, - }) - .pipe( + this.badgeService.setState(StateName, (tab) => { + return combineLatest({ + account: this.accountService.activeAccount$, + enableBadgeCounter: + this.badgeSettingsService.enableBadgeCounter$.pipe(distinctUntilChanged()), + ciphers: ciphers$.pipe(delay(100)), // Delay to allow cipherService.getAllDecryptedForUrl to pick up changes + }).pipe( mergeMap(async ({ account, enableBadgeCounter }) => { - if (!account) { - return; - } - - const tabs = await this.badgeService.getActiveTabs(); - - for (const tab of tabs) { - if (!tab.tabId) { - continue; - } - if (enableBadgeCounter) { - await this.setTabState(tab, account.id); - } else { - await this.clearTabState(tab.tabId); - } - } - }), - ) - .subscribe(); - - // Recalculate badge for a specific tab when it becomes active - this.badgeService.activeTabsUpdated$ - .pipe( - withLatestFrom( - this.accountService.activeAccount$, - this.badgeSettingsService.enableBadgeCounter$, - ), - mergeMap(async ([tabs, account, enableBadgeCounter]) => { if (!account || !enableBadgeCounter) { - return; + return undefined; } - for (const tab of tabs) { - await this.setTabState(tab, account.id); - } + return { + state: { + text: await this.calculateCountText(tab, account.id), + }, + priority: BadgeStatePriority.Default, + }; }), - ) - .subscribe(); + ); + }); } - private async setTabState(tab: Tab, userId: UserId) { + private async calculateCountText(tab: Tab, userId: UserId) { if (!tab.tabId) { this.logService.warning("Tab event received but tab id is undefined"); return; @@ -85,22 +59,9 @@ export class AutofillBadgeUpdaterService { const cipherCount = ciphers.length; if (cipherCount === 0) { - await this.clearTabState(tab.tabId); - return; + return undefined; } - const countText = cipherCount > 9 ? "9+" : cipherCount.toString(); - await this.badgeService.setState( - StateName(tab.tabId), - BadgeStatePriority.Default, - { - text: countText, - }, - tab.tabId, - ); - } - - private async clearTabState(tabId: number) { - await this.badgeService.clearState(StateName(tabId)); + return cipherCount > 9 ? "9+" : cipherCount.toString(); } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 0820f605a0a..7b62178d237 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1435,7 +1435,6 @@ export default class MainBackground { ); this.badgeService = new BadgeService( - this.stateProvider, new DefaultBadgeBrowserApi(this.platformUtilsService), this.logService, ); @@ -1925,7 +1924,6 @@ export default class MainBackground { this.badgeService, this.accountService, this.cipherService, - this.logService, this.taskService, ); diff --git a/apps/browser/src/platform/badge/badge-browser-api.ts b/apps/browser/src/platform/badge/badge-browser-api.ts index 79b50970400..80f84c3b46e 100644 --- a/apps/browser/src/platform/badge/badge-browser-api.ts +++ b/apps/browser/src/platform/badge/badge-browser-api.ts @@ -1,4 +1,16 @@ -import { concat, defer, filter, map, merge, Observable, shareReplay, switchMap } from "rxjs"; +import { + concat, + concatMap, + defer, + filter, + map, + merge, + Observable, + of, + pairwise, + shareReplay, + switchMap, +} from "rxjs"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -28,13 +40,37 @@ function tabFromChromeTab(tab: chrome.tabs.Tab): Tab { } export interface BadgeBrowserApi { - activeTabsUpdated$: Observable; + /** + * An observable that emits all currently active tabs whenever one or more active tabs change. + */ + activeTabs$: Observable; + /** + * An observable that emits tab events such as updates and activations. + */ + tabEvents$: Observable; + + /** + * Set the badge state for a specific tab. + * If the tabId is undefined the state will be applied to the browser action in general. + */ setState(state: RawBadgeState, tabId?: number): Promise; - getTabs(): Promise; - getActiveTabs(): Promise; } +export type TabEvent = + | { + type: "updated"; + tab: Tab; + } + | { + type: "activated"; + tab: Tab; + } + | { + type: "deactivated"; + tabId: number; + }; + export class DefaultBadgeBrowserApi implements BadgeBrowserApi { private badgeAction = BrowserApi.getBrowserAction(); private sidebarAction = BrowserApi.getSidebarAction(self); @@ -44,18 +80,25 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi { shareReplay({ bufferSize: 1, refCount: true }), ); - activeTabsUpdated$ = concat( - defer(async () => await this.getActiveTabs()), + private createdOrUpdatedTabEvents$ = concat( + defer(async () => await this.getActiveTabs()).pipe( + switchMap((activeTabs) => { + const tabEvents: TabEvent[] = activeTabs.map((tab) => ({ + type: "activated", + tab, + })); + return of(...tabEvents); + }), + ), merge( this.onTabActivated$.pipe( - switchMap(async (activeInfo) => { - const tab = await BrowserApi.getTab(activeInfo.tabId); - - if (tab == undefined || tab.id == undefined || tab.url == undefined) { - return []; - } - - return [tabFromChromeTab(tab)]; + switchMap(async (activeInfo) => await BrowserApi.getTab(activeInfo.tabId)), + filter( + (tab): tab is chrome.tabs.Tab => + !(tab == undefined || tab.id == undefined || tab.url == undefined), + ), + switchMap(async (tab) => { + return { type: "activated", tab: tabFromChromeTab(tab) } satisfies TabEvent; }), ), fromChromeEvent(chrome.tabs.onUpdated).pipe( @@ -64,22 +107,58 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi { // Only emit if the url was updated changeInfo.url != undefined, ), - map(([_tabId, _changeInfo, tab]) => [tabFromChromeTab(tab)]), + map( + ([_tabId, _changeInfo, tab]) => + ({ type: "updated", tab: tabFromChromeTab(tab) }) satisfies TabEvent, + ), ), fromChromeEvent(chrome.webNavigation.onCommitted).pipe( + filter(([details]) => details.transitionType === "reload"), map(([details]) => { - const toReturn: Tab[] = - details.transitionType === "reload" ? [{ tabId: details.tabId, url: details.url }] : []; - return toReturn; + return { + type: "updated", + tab: { tabId: details.tabId, url: details.url }, + } satisfies TabEvent; }), ), // NOTE: We're only sharing the active tab changes, not the full list of active tabs. // This is so that any new subscriber will get the latest active tabs immediately, but // doesn't re-subscribe to chrome events. ).pipe(shareReplay({ bufferSize: 1, refCount: true })), - ).pipe(filter((tabs) => tabs.length > 0)); + ); + + tabEvents$ = merge( + this.createdOrUpdatedTabEvents$, + this.createdOrUpdatedTabEvents$.pipe( + concatMap(async () => { + return this.getActiveTabs(); + }), + pairwise(), + map(([previousTabs, currentTabs]) => { + const previousTabIds = previousTabs.map((t) => t.tabId); + const currentTabIds = currentTabs.map((t) => t.tabId); + + const deactivatedTabIds = previousTabIds.filter((id) => !currentTabIds.includes(id)); + + return deactivatedTabIds.map( + (tabId) => + ({ + type: "deactivated", + tabId, + }) satisfies TabEvent, + ); + }), + switchMap((events) => of(...events)), + ), + ); - async getActiveTabs(): Promise { + activeTabs$ = this.tabEvents$.pipe( + concatMap(async () => { + return this.getActiveTabs(); + }), + ); + + private async getActiveTabs(): Promise { const tabs = await BrowserApi.getActiveTabs(); return tabs.filter((tab) => tab.id != undefined && tab.url != undefined).map(tabFromChromeTab); } @@ -96,10 +175,6 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi { ]); } - async getTabs(): Promise { - return (await BrowserApi.tabsQuery({})).map((tab) => tab.id).filter((tab) => tab !== undefined); - } - private setIcon(icon: IconPaths, tabId?: number) { return Promise.all([this.setActionIcon(icon, tabId), this.setSidebarActionIcon(icon, tabId)]); } diff --git a/apps/browser/src/platform/badge/badge.service.spec.ts b/apps/browser/src/platform/badge/badge.service.spec.ts index d17e5dc0b5f..815941541e6 100644 --- a/apps/browser/src/platform/badge/badge.service.spec.ts +++ b/apps/browser/src/platform/badge/badge.service.spec.ts @@ -1,11 +1,10 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { Subscription } from "rxjs"; +import { EMPTY, Observable, of, Subscription } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec"; import { RawBadgeState } from "./badge-browser-api"; -import { BadgeService } from "./badge.service"; +import { BadgeService, BadgeStateFunction } from "./badge.service"; import { DefaultBadgeState } from "./consts"; import { BadgeIcon } from "./icon"; import { BadgeStatePriority } from "./priority"; @@ -14,7 +13,6 @@ import { MockBadgeBrowserApi } from "./test/mock-badge-browser-api"; describe("BadgeService", () => { let badgeApi: MockBadgeBrowserApi; - let stateProvider: FakeStateProvider; let logService!: MockProxy; let badgeService!: BadgeService; @@ -22,626 +20,834 @@ describe("BadgeService", () => { beforeEach(() => { badgeApi = new MockBadgeBrowserApi(); - stateProvider = new FakeStateProvider(new FakeAccountService({})); logService = mock(); - badgeService = new BadgeService(stateProvider, badgeApi, logService); + badgeService = new BadgeService(badgeApi, logService, 0); }); afterEach(() => { badgeServiceSubscription?.unsubscribe(); }); - describe("calling without tabId", () => { - const tabId = 1; - - describe("given a single tab is open", () => { - beforeEach(() => { - badgeApi.tabs = [tabId]; - badgeApi.setActiveTabs([tabId]); - badgeServiceSubscription = badgeService.startListening(); - }); + describe("static state", () => { + describe("calling without tabId", () => { + const tabId = 1; - it("sets provided state when no other state has been set", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; + describe("given a single tab is open", () => { + beforeEach(() => { + badgeApi.tabs = [tabId]; + badgeApi.setActiveTabs([tabId]); + badgeServiceSubscription = badgeService.startListening(); + }); - await badgeService.setState("state-name", BadgeStatePriority.Default, state); + it("sets provided state when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(state); - }); + await badgeService.setState( + "state-name", + GeneralStateFunction(BadgeStatePriority.Default, state), + ); - it("sets default values when none are provided", async () => { - // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit - const state: BadgeState = {}; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(state); + }); - await badgeService.setState("state-name", BadgeStatePriority.Default, state); + it("sets default values when none are provided", async () => { + // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit + const state: BadgeState = {}; - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - }); + await badgeService.setState( + "state-name", + GeneralStateFunction(BadgeStatePriority.Default, state), + ); - it("merges states when multiple same-priority states have been set", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Default, { text: "text" }); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - backgroundColor: "#fff", - }); - await badgeService.setState("state-3", BadgeStatePriority.Default, { - icon: BadgeIcon.Locked, + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); }); - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); + it("sets default values even if state function never emits", async () => { + badgeService.setState("state-name", (_tab) => EMPTY); - it("overrides previous lower-priority state when higher-priority state is set", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - text: "override", - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - backgroundColor: "#aaa", + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); }); - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "override", - backgroundColor: "#aaa", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - - it("removes override when a previously high-priority state is cleared", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - text: "override", + it("merges states when multiple same-priority states have been set", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Default, { text: "text" }), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + backgroundColor: "#fff", + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.Default, { + icon: BadgeIcon.Locked, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); - await badgeService.clearState("state-2"); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - it("sets default values when all states have been cleared", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - text: "override", - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - backgroundColor: "#aaa", + it("overrides previous lower-priority state when higher-priority state is set", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + text: "override", + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + backgroundColor: "#aaa", + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "override", + backgroundColor: "#aaa", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); - await badgeService.clearState("state-1"); - await badgeService.clearState("state-2"); - await badgeService.clearState("state-3"); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - }); - it("sets default value high-priority state contains Unset", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - icon: Unset, + it("removes override when a previously high-priority state is cleared", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + text: "override", + }), + ); + await badgeService.clearState("state-2"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: DefaultBadgeState.icon, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - - it("ignores medium-priority Unset when high-priority contains a value", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-3", BadgeStatePriority.Default, { - icon: Unset, + it("sets default values when all states have been cleared", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + text: "override", + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + backgroundColor: "#aaa", + }), + ); + await badgeService.clearState("state-1"); + await badgeService.clearState("state-2"); + await badgeService.clearState("state-3"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - icon: BadgeIcon.Unlocked, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Unlocked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - }); - - describe("given multiple tabs are open, only one active", () => { - const tabId = 1; - const tabIds = [1, 2, 3]; - beforeEach(() => { - badgeApi.tabs = tabIds; - badgeApi.setActiveTabs([tabId]); - badgeServiceSubscription = badgeService.startListening(); - }); - - it("sets general state for active tab when no other state has been set", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("state-name", BadgeStatePriority.Default, state); + it("sets default value high-priority state contains Unset", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + icon: Unset, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: DefaultBadgeState.icon, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - 1: state, - 2: undefined, - 3: undefined, + it("ignores medium-priority Unset when high-priority contains a value", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.Default, { + icon: Unset, + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + icon: BadgeIcon.Unlocked, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Unlocked, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); }); - it("only updates the active tab when setting state", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - badgeApi.setState.mockReset(); - - await badgeService.setState("state-1", BadgeStatePriority.Default, state, tabId); - await badgeService.setState("state-2", BadgeStatePriority.Default, state, 2); - await badgeService.setState("state-2", BadgeStatePriority.Default, state, 2); + describe("given multiple tabs are open, only one active", () => { + const tabId = 1; + const tabIds = [1, 2, 3]; - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.setState).toHaveBeenCalledTimes(1); - }); - }); + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeApi.setActiveTabs([tabId]); + badgeServiceSubscription = badgeService.startListening(); + }); - describe("given multiple tabs are open and multiple are active", () => { - const activeTabIds = [1, 2]; - const tabIds = [1, 2, 3]; + it("sets general state for active tab when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "state-name", + GeneralStateFunction(BadgeStatePriority.Default, state), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + 1: state, + 2: undefined, + 3: undefined, + }); + }); - beforeEach(() => { - badgeApi.tabs = tabIds; - badgeApi.setActiveTabs(activeTabIds); - badgeServiceSubscription = badgeService.startListening(); + it("only updates the active tab when setting state", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + await badgeService.setState( + "state-1", + TabSpecificStateFunction(BadgeStatePriority.Default, state, tabId), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 2), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 2), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + badgeApi.setState.mockReset(); + badgeApi.updateTab(tabId); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.setState).toHaveBeenCalledTimes(1); + }); }); - it("sets general state for active tabs when no other state has been set", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("state-name", BadgeStatePriority.Default, state); + describe("given multiple tabs are open and multiple are active", () => { + const activeTabIds = [1, 2]; + const tabIds = [1, 2, 3]; - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - 1: state, - 2: state, - 3: undefined, + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeApi.setActiveTabs(activeTabIds); + badgeServiceSubscription = badgeService.startListening(); }); - }); - - it("only updates the active tabs when setting general state", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - badgeApi.setState.mockReset(); - await badgeService.setState("state-1", BadgeStatePriority.Default, state, 1); - await badgeService.setState("state-2", BadgeStatePriority.Default, state, 2); - await badgeService.setState("state-3", BadgeStatePriority.Default, state, 3); + it("sets general state for active tabs when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "state-name", + GeneralStateFunction(BadgeStatePriority.Default, state), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + 1: state, + 2: state, + 3: undefined, + }); + }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.setState).toHaveBeenCalledTimes(2); + it("only updates the active tabs when setting general state", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "state-1", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 1), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 2), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 3), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + badgeApi.setState.mockReset(); + badgeApi.updateTab(activeTabIds[0]); + badgeApi.updateTab(activeTabIds[1]); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.setState).toHaveBeenCalledTimes(2); + }); }); }); - }); - - describe("calling with tabId", () => { - describe("given a single tab is open", () => { - const tabId = 1; - - beforeEach(() => { - badgeApi.tabs = [tabId]; - badgeApi.setActiveTabs([tabId]); - badgeServiceSubscription = badgeService.startListening(); - }); - it("sets provided state when no other state has been set", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; + describe("setting tab-specific states", () => { + describe("given a single tab is open", () => { + const tabId = 1; - await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId); + beforeEach(() => { + badgeApi.tabs = [tabId]; + badgeApi.setActiveTabs([tabId]); + badgeServiceSubscription = badgeService.startListening(); + }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(state); - }); + it("sets provided state when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; - it("sets default values when none are provided", async () => { - // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit - const state: BadgeState = {}; + await badgeService.setState( + "state-name", + TabSpecificStateFunction(BadgeStatePriority.Default, state, tabId), + ); - await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(state); + }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - }); + it("sets default values when none are provided", async () => { + // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit + const state: BadgeState = {}; - it("merges tabId specific state with general states", async () => { - await badgeService.setState("general-state", BadgeStatePriority.Default, { text: "text" }); - await badgeService.setState( - "specific-state", - BadgeStatePriority.Default, - { - backgroundColor: "#fff", - }, - tabId, - ); - await badgeService.setState("general-state-2", BadgeStatePriority.Default, { - icon: BadgeIcon.Locked, - }); + await badgeService.setState( + "state-name", + TabSpecificStateFunction(BadgeStatePriority.Default, state, tabId), + ); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); }); - }); - it("merges states when multiple same-priority states with the same tabId have been set", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Default, { text: "text" }, tabId); - await badgeService.setState( - "state-2", - BadgeStatePriority.Default, - { + it("merges tabId specific state with general states", async () => { + await badgeService.setState( + "general-state", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + text: "text", + }, + tabId, + ), + ); + await badgeService.setState( + "specific-state", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + backgroundColor: "#fff", + }, + tabId, + ), + ); + await badgeService.setState( + "general-state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + icon: BadgeIcon.Locked, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", backgroundColor: "#fff", - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.Default, - { icon: BadgeIcon.Locked, - }, - tabId, - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); + }); + }); - it("overrides previous lower-priority state when higher-priority state with the same tabId is set", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { + it("merges states when multiple same-priority states with the same tabId have been set", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction(BadgeStatePriority.Default, { text: "text" }, tabId), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + backgroundColor: "#fff", + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { text: "text", backgroundColor: "#fff", icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-2", - BadgeStatePriority.Default, - { + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("overrides previous lower-priority state when higher-priority state with the same tabId is set", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.High, + { + backgroundColor: "#aaa", + }, + tabId, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { text: "override", - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.High, - { backgroundColor: "#aaa", - }, - tabId, - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "override", - backgroundColor: "#aaa", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - - it("overrides lower-priority tab-specific state when higher-priority general state is set", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { - text: "text", - backgroundColor: "#fff", icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - text: "override", - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - backgroundColor: "#aaa", + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "override", - backgroundColor: "#aaa", - icon: BadgeIcon.Locked, + it("overrides lower-priority tab-specific state when higher-priority general state is set", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + text: "override", + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + backgroundColor: "#aaa", + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "override", + backgroundColor: "#aaa", + icon: BadgeIcon.Locked, + }); }); - }); - it("removes override when a previously high-priority state with the same tabId is cleared", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { + it("removes override when a previously high-priority state with the same tabId is cleared", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ), + ); + await badgeService.clearState("state-2"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ text: "text", backgroundColor: "#fff", icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-2", - BadgeStatePriority.Default, - { - text: "override", - }, - tabId, - ); - await badgeService.clearState("state-2"); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, + }); }); - }); - it("sets default state when all states with the same tabId have been cleared", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-2", - BadgeStatePriority.Default, - { - text: "override", - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.High, - { - backgroundColor: "#aaa", - }, - tabId, - ); - await badgeService.clearState("state-1"); - await badgeService.clearState("state-2"); - await badgeService.clearState("state-3"); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - }); + it("sets default state when all states with the same tabId have been cleared", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.High, + { + backgroundColor: "#aaa", + }, + tabId, + ), + ); + await badgeService.clearState("state-1"); + await badgeService.clearState("state-2"); + await badgeService.clearState("state-3"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); - it("sets default value when high-priority state contains Unset", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { + it("sets default value when high-priority state contains Unset", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.High, + { + icon: Unset, + }, + tabId, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ text: "text", backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.High, - { - icon: Unset, - }, - tabId, - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "text", - backgroundColor: "#fff", - icon: DefaultBadgeState.icon, + icon: DefaultBadgeState.icon, + }); }); - }); - it("ignores medium-priority Unset when high-priority contains a value", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { + it("ignores medium-priority Unset when high-priority contains a value", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + icon: Unset, + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.High, + { + icon: BadgeIcon.Unlocked, + }, + tabId, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ text: "text", backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.Default, - { - icon: Unset, - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.High, - { icon: BadgeIcon.Unlocked, - }, - tabId, - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Unlocked, + }); }); }); - }); - describe("given multiple tabs are open, only one active", () => { - const tabId = 1; - const tabIds = [1, 2, 3]; + describe("given multiple tabs are open, only one active", () => { + const tabId = 1; + const tabIds = [1, 2, 3]; - beforeEach(() => { - badgeApi.tabs = tabIds; - badgeApi.setActiveTabs([tabId]); - badgeServiceSubscription = badgeService.startListening(); - }); + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeApi.setActiveTabs([tabId]); + badgeServiceSubscription = badgeService.startListening(); + }); - it("sets tab-specific state for provided tab", async () => { - const generalState: BadgeState = { - text: "general-text", - backgroundColor: "general-color", - icon: BadgeIcon.Unlocked, - }; - const specificState: BadgeState = { - text: "tab-text", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("general-state", BadgeStatePriority.Default, generalState); - await badgeService.setState( - "tab-state", - BadgeStatePriority.Default, - specificState, - tabIds[0], - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, - [tabIds[1]]: undefined, - [tabIds[2]]: undefined, + it("sets tab-specific state for provided tab", async () => { + const generalState: BadgeState = { + text: "general-text", + backgroundColor: "general-color", + icon: BadgeIcon.Unlocked, + }; + const specificState: BadgeState = { + text: "tab-text", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "general-state", + GeneralStateFunction(BadgeStatePriority.Default, generalState), + ); + await badgeService.setState( + "tab-state", + TabSpecificStateFunction(BadgeStatePriority.Default, specificState, tabIds[0]), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, + [tabIds[1]]: undefined, + [tabIds[2]]: undefined, + }); }); }); - }); - - describe("given multiple tabs are open and multiple are active", () => { - const activeTabIds = [1, 2]; - const tabIds = [1, 2, 3]; - beforeEach(() => { - badgeApi.tabs = tabIds; - badgeApi.setActiveTabs(activeTabIds); - badgeServiceSubscription = badgeService.startListening(); - }); + describe("given multiple tabs are open and multiple are active", () => { + const activeTabIds = [1, 2]; + const tabIds = [1, 2, 3]; - it("sets general state for all active tabs when no other state has been set", async () => { - const generalState: BadgeState = { - text: "general-text", - backgroundColor: "general-color", - icon: BadgeIcon.Unlocked, - }; + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeApi.setActiveTabs(activeTabIds); + badgeServiceSubscription = badgeService.startListening(); + }); - await badgeService.setState("general-state", BadgeStatePriority.Default, generalState); + it("sets general state for all active tabs when no other state has been set", async () => { + const generalState: BadgeState = { + text: "general-text", + backgroundColor: "general-color", + icon: BadgeIcon.Unlocked, + }; + + await badgeService.setState( + "general-state", + GeneralStateFunction(BadgeStatePriority.Default, generalState), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + [tabIds[0]]: generalState, + [tabIds[1]]: generalState, + [tabIds[2]]: undefined, + }); + }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - [tabIds[0]]: generalState, - [tabIds[1]]: generalState, - [tabIds[2]]: undefined, + it("sets tab-specific state for provided tab", async () => { + const generalState: BadgeState = { + text: "general-text", + backgroundColor: "general-color", + icon: BadgeIcon.Unlocked, + }; + const specificState: BadgeState = { + text: "tab-text", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "general-state", + GeneralStateFunction(BadgeStatePriority.Default, generalState), + ); + await badgeService.setState( + "tab-state", + TabSpecificStateFunction(BadgeStatePriority.Default, specificState, tabIds[0]), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, + [tabIds[1]]: generalState, + [tabIds[2]]: undefined, + }); }); - }); - it("sets tab-specific state for provided tab", async () => { - const generalState: BadgeState = { - text: "general-text", - backgroundColor: "general-color", - icon: BadgeIcon.Unlocked, - }; - const specificState: BadgeState = { - text: "tab-text", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("general-state", BadgeStatePriority.Default, generalState); - await badgeService.setState( - "tab-state", - BadgeStatePriority.Default, - specificState, - tabIds[0], - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, - [tabIds[1]]: generalState, - [tabIds[2]]: undefined, + it("unsubscribes from state function when tab is deactivated", async () => { + let subscriptions = 0; + badgeService.setState("state", (tab) => { + return new Observable(() => { + subscriptions++; + return () => { + subscriptions--; + }; + }); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(subscriptions).toBe(activeTabIds.length); + + badgeApi.deactivateTab(activeTabIds[0]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(subscriptions).toBe(activeTabIds.length - 1); }); }); }); }); }); + +/** + * Creates a dynamic state function that only provides a state for a specific tab. + */ +function TabSpecificStateFunction( + priority: BadgeStatePriority, + state: BadgeState, + tabId: number, +): BadgeStateFunction { + return (tab) => { + if (tab.tabId === tabId) { + return of({ + priority, + state, + }); + } + + return EMPTY; + }; +} + +/** + * Creates a dynamic state function that provides the same state for all tabs. + */ +function GeneralStateFunction(priority: BadgeStatePriority, state: BadgeState): BadgeStateFunction { + return (_tab) => + of({ + priority, + state, + }); +} diff --git a/apps/browser/src/platform/badge/badge.service.ts b/apps/browser/src/platform/badge/badge.service.ts index 5634aabec28..f6d799b2a80 100644 --- a/apps/browser/src/platform/badge/badge.service.ts +++ b/apps/browser/src/platform/badge/badge.service.ts @@ -1,69 +1,100 @@ -import { concatMap, filter, Subscription, withLatestFrom } from "rxjs"; +import { + BehaviorSubject, + combineLatest, + combineLatestWith, + concatMap, + debounceTime, + filter, + groupBy, + map, + mergeMap, + Observable, + of, + startWith, + Subscription, + switchMap, + takeUntil, +} from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { - BADGE_MEMORY, - GlobalState, - KeyDefinition, - StateProvider, -} from "@bitwarden/common/platform/state"; import { BadgeBrowserApi, RawBadgeState, Tab } from "./badge-browser-api"; import { DefaultBadgeState } from "./consts"; import { BadgeStatePriority } from "./priority"; import { BadgeState, Unset } from "./state"; -interface StateSetting { +const BADGE_UPDATE_DEBOUNCE_MS = 100; + +export interface BadgeStateSetting { priority: BadgeStatePriority; state: BadgeState; - tabId?: number; } -const BADGE_STATES = new KeyDefinition(BADGE_MEMORY, "badgeStates", { - deserializer: (value: Record) => value ?? {}, - cleanupDelayMs: 0, -}); +/** + * A function that returns the badge state for a specific tab. + * Return `undefined` to clear any previously set state for the tab. + */ +export type BadgeStateFunction = (tab: Tab) => Observable; export class BadgeService { - private serviceState: GlobalState>; - - /** - * An observable that emits whenever one or multiple tabs are updated and might need its state updated. - * Use this to know exactly which tabs to calculate the badge state for. - * This is not the same as `onActivated` which only emits when the active tab changes. - */ - activeTabsUpdated$ = this.badgeApi.activeTabsUpdated$; - - getActiveTabs(): Promise { - return this.badgeApi.getActiveTabs(); - } + private stateFunctions = new BehaviorSubject>({}); constructor( - private stateProvider: StateProvider, private badgeApi: BadgeBrowserApi, private logService: LogService, - ) { - this.serviceState = this.stateProvider.getGlobal(BADGE_STATES); - } + private debounceTimeMs: number = BADGE_UPDATE_DEBOUNCE_MS, + ) {} /** * Start listening for badge state changes. * Without this the service will not be able to update the badge state. */ startListening(): Subscription { - // React to tab changes - return this.badgeApi.activeTabsUpdated$ + // Default state function that always returns an empty state with lowest priority. + // This will ensure that there is always at least one state to consider when calculating the final badge state, + // so that the badge is cleared/set to default when no other states are set. + const defaultTabStateFunction: BadgeStateFunction = (_tab) => + of({ + priority: BadgeStatePriority.Low, + state: {}, + }); + + return this.badgeApi.tabEvents$ .pipe( - withLatestFrom(this.serviceState.state$), - filter(([activeTabs]) => activeTabs.length > 0), - concatMap(async ([activeTabs, serviceState]) => { - await Promise.all(activeTabs.map((tab) => this.updateBadge(serviceState, tab.tabId))); + groupBy((event) => (event.type === "deactivated" ? event.tabId : event.tab.tabId), { + duration: (group$) => + // Allow clean up of group when deactivated event arrives for this tabId + group$.pipe(filter((evt) => evt.type === "deactivated")), + }), + mergeMap((group$) => + group$.pipe( + // ignore deactivation events, only handle updates/activations + filter((evt) => evt.type !== "deactivated"), + map((evt) => evt.tab), + combineLatestWith(this.stateFunctions), + switchMap(([tab, dynamicStateFunctions]) => { + const functions = [...Object.values(dynamicStateFunctions), defaultTabStateFunction]; + + return combineLatest(functions.map((f) => f(tab).pipe(startWith(undefined)))).pipe( + map((states) => ({ + tab, + states: states.filter((s): s is BadgeStateSetting => s !== undefined), + })), + debounceTime(this.debounceTimeMs), + ); + }), + takeUntil(group$.pipe(filter((evt) => evt.type === "deactivated"))), + ), + ), + + concatMap(async (tabUpdate) => { + await this.updateBadge(tabUpdate.states, tabUpdate.tab.tabId); }), ) .subscribe({ error: (error: unknown) => { this.logService.error( - "Fatal error in badge service observable, badge will fail to update", + "BadgeService: Fatal error updating badge state. Badge will no longer be updated.", error, ); }, @@ -71,68 +102,45 @@ export class BadgeService { } /** - * Inform badge service of a new state that the badge should reflect. + * Register a function that takes an observable of active tab updates and returns an observable of state settings. + * This can be used to create dynamic badge states that react to tab changes. + * The returned observable should emit a new state setting whenever the badge state should be updated. * - * This will merge the new state with any existing states: + * This will merge all states: * - If the new state has a higher priority, it will override any lower priority states. * - If the new state has a lower priority, it will be ignored. * - If the name of the state is already in use, it will be updated. * - If the state has a `tabId` set, it will only apply to that tab. * - States with `tabId` can still be overridden by states without `tabId` if they have a higher priority. - * - * @param name The name of the state. This is used to identify the state and will be used to clear it later. - * @param priority The priority of the state (higher numbers are higher priority, but setting arbitrary numbers is not supported). - * @param state The state to set. - * @param tabId Limit this badge state to a specific tab. If this is not set, the state will be applied to all tabs. */ - async setState(name: string, priority: BadgeStatePriority, state: BadgeState, tabId?: number) { - const newServiceState = await this.serviceState.update((s) => ({ - ...s, - [name]: { priority, state, tabId }, - })); - await this.updateBadge(newServiceState, tabId); + setState(name: string, stateFunction: BadgeStateFunction) { + this.stateFunctions.next({ + ...this.stateFunctions.value, + [name]: stateFunction, + }); } /** - * Clear the state with the given name. + * Clear a state function previously registered with `setState`. * - * This will remove the state from the badge service and clear it from the badge. - * If the state is not found, nothing will happen. + * This will: + * - Stop the function from being called on future tab changes + * - Unsubscribe from any existing observables created by the function. + * - Clear any badge state previously set by the function. * - * @param name The name of the state to clear. + * @param name The name of the state function to clear. */ - async clearState(name: string) { - let clearedState: StateSetting | undefined; - - const newServiceState = await this.serviceState.update((s) => { - clearedState = s?.[name]; - - const newStates = { ...s }; - delete newStates[name]; - return newStates; - }); - - if (clearedState === undefined) { - return; - } - await this.updateBadge(newServiceState, clearedState.tabId); + clearState(name: string) { + const currentDynamicStateFunctions = this.stateFunctions.value; + const newDynamicStateFunctions = { ...currentDynamicStateFunctions }; + delete newDynamicStateFunctions[name]; + this.stateFunctions.next(newDynamicStateFunctions); } - private calculateState(states: Set, tabId?: number): RawBadgeState { - const sortedStates = [...states].sort((a, b) => a.priority - b.priority); - - let filteredStates = sortedStates; - if (tabId !== undefined) { - // Filter out states that are not applicable to the current tab. - // If a state has no tabId, it is considered applicable to all tabs. - // If a state has a tabId, it is only applicable to that tab. - filteredStates = sortedStates.filter((s) => s.tabId === tabId || s.tabId === undefined); - } else { - // If no tabId is provided, we only want states that are not tab-specific. - filteredStates = sortedStates.filter((s) => s.tabId === undefined); - } + private calculateState(states: BadgeStateSetting[]): RawBadgeState { + const sortedStates = states.sort((a, b) => a.priority - b.priority); - const mergedState = filteredStates + const mergedState = sortedStates .map((s) => s.state) .reduce>((acc: Partial, state: BadgeState) => { const newState = { ...acc }; @@ -156,43 +164,16 @@ export class BadgeService { * This will only update the badge if the active tab is the same as the tabId of the latest change. * If the active tab is not set, it will not update the badge. * - * @param activeTab The currently active tab. * @param serviceState The current state of the badge service. If this is null or undefined, an empty set will be assumed. * @param tabId Tab id for which the the latest state change applied to. Set this to activeTab.tabId to force an update. + * @param activeTabs The currently active tabs. If not provided, it will be fetched from the badge API. */ - private async updateBadge( - serviceState: Record | null | undefined, - tabId: number | undefined, - ) { - const activeTabs = await this.badgeApi.getActiveTabs(); - if (tabId !== undefined && !activeTabs.some((tab) => tab.tabId === tabId)) { - return; // No need to update the badge if the state is not for the active tab. - } - - const tabIdsToUpdate = tabId ? [tabId] : activeTabs.map((tab) => tab.tabId); - - for (const tabId of tabIdsToUpdate) { - if (tabId === undefined) { - continue; // Skip if tab id is undefined. - } - - const newBadgeState = this.calculateState(new Set(Object.values(serviceState ?? {})), tabId); - try { - await this.badgeApi.setState(newBadgeState, tabId); - } catch (error) { - this.logService.error("Failed to set badge state", error); - } - } - - if (tabId === undefined) { - // If no tabId was provided we should also update the general badge state - const newBadgeState = this.calculateState(new Set(Object.values(serviceState ?? {}))); - - try { - await this.badgeApi.setState(newBadgeState, tabId); - } catch (error) { - this.logService.error("Failed to set general badge state", error); - } + private async updateBadge(serviceState: BadgeStateSetting[], tabId: number) { + const newBadgeState = this.calculateState(serviceState); + try { + await this.badgeApi.setState(newBadgeState, tabId); + } catch (error) { + this.logService.error("Failed to set badge state", error); } } } diff --git a/apps/browser/src/platform/badge/scope.ts b/apps/browser/src/platform/badge/scope.ts new file mode 100644 index 00000000000..5d6cb8dd4e7 --- /dev/null +++ b/apps/browser/src/platform/badge/scope.ts @@ -0,0 +1,23 @@ +export const BadgeStateScope = { + /** + * The state is global and applies to all users. + */ + Global: { type: "global" } satisfies BadgeStateScope, + /** + * The state is for a specific user and only applies to that user when they are unlocked. + */ + UserUnlocked: (userId: string) => + ({ + type: "user_unlocked", + userId, + }) satisfies BadgeStateScope, +} as const; + +export type BadgeStateScope = + | { + type: "global"; + } + | { + type: "user_unlocked"; + userId: string; + }; diff --git a/apps/browser/src/platform/badge/state.ts b/apps/browser/src/platform/badge/state.ts index 0731ad81f41..ea6b52b28e4 100644 --- a/apps/browser/src/platform/badge/state.ts +++ b/apps/browser/src/platform/badge/state.ts @@ -1,6 +1,8 @@ import { BadgeIcon } from "./icon"; -export const Unset = Symbol("Unset badge state"); +const UnsetValue = Symbol("Unset badge state"); + +export const Unset = UnsetValue as typeof UnsetValue; export type Unset = typeof Unset; export type BadgeState = { diff --git a/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts b/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts index a1b79c29cb8..9f8db3f23ef 100644 --- a/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts +++ b/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts @@ -1,33 +1,50 @@ -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, concat, defer, of, Subject, switchMap } from "rxjs"; -import { BadgeBrowserApi, RawBadgeState, Tab } from "../badge-browser-api"; +import { BadgeBrowserApi, RawBadgeState, Tab, TabEvent } from "../badge-browser-api"; export class MockBadgeBrowserApi implements BadgeBrowserApi { - private _activeTabsUpdated$ = new BehaviorSubject([]); - activeTabsUpdated$ = this._activeTabsUpdated$.asObservable(); + private _activeTabs$ = new BehaviorSubject([]); + private _tabEvents$ = new Subject(); + activeTabs$ = this._activeTabs$.asObservable(); specificStates: Record = {}; generalState?: RawBadgeState; tabs: number[] = []; - activeTabs: number[] = []; - - getActiveTabs(): Promise { - return Promise.resolve( - this.activeTabs.map( - (tabId) => - ({ - tabId, - url: `https://example.com/${tabId}`, - }) satisfies Tab, - ), - ); + + tabEvents$ = concat( + defer(() => [this.activeTabs]).pipe( + switchMap((activeTabs) => { + const tabEvents: TabEvent[] = activeTabs.map((tab) => ({ + type: "activated", + tab, + })); + return of(...tabEvents); + }), + ), + this._tabEvents$.asObservable(), + ); + + get activeTabs() { + return this._activeTabs$.value; } setActiveTabs(tabs: number[]) { - this.activeTabs = tabs; - this._activeTabsUpdated$.next( - tabs.map((tabId) => ({ tabId, url: `https://example.com/${tabId}` })), - ); + this._activeTabs$.next(tabs.map((tabId) => ({ tabId, url: `https://example.com/${tabId}` }))); + + tabs.forEach((tabId) => { + this._tabEvents$.next({ + type: "activated", + tab: { tabId, url: `https://example.com/${tabId}` }, + }); + }); + } + + updateTab(tabId: number) { + this._tabEvents$.next({ type: "updated", tab: { tabId, url: `https://example.com/${tabId}` } }); + } + + deactivateTab(tabId: number) { + this._tabEvents$.next({ type: "deactivated", tabId }); } setState = jest.fn().mockImplementation((state: RawBadgeState, tabId?: number): Promise => { @@ -39,8 +56,4 @@ export class MockBadgeBrowserApi implements BadgeBrowserApi { return Promise.resolve(); }); - - getTabs(): Promise { - return Promise.resolve(this.tabs); - } } diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts index f2567ef4267..b84d17a8375 100644 --- a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts @@ -1,12 +1,11 @@ -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks"; -import { LogService } from "@bitwarden/logging"; -import { UserId } from "@bitwarden/user-core"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; -import { BadgeService } from "../../platform/badge/badge.service"; +import { Tab } from "../../platform/badge/badge-browser-api"; +import { BadgeService, BadgeStateFunction } from "../../platform/badge/badge.service"; import { BadgeIcon } from "../../platform/badge/icon"; import { BadgeStatePriority } from "../../platform/badge/priority"; import { Unset } from "../../platform/badge/state"; @@ -18,34 +17,32 @@ describe("AtRiskCipherBadgeUpdaterService", () => { let service: AtRiskCipherBadgeUpdaterService; let setState: jest.Mock; - let clearState: jest.Mock; - let warning: jest.Mock; let getAllDecryptedForUrl: jest.Mock; let getTab: jest.Mock; let addListener: jest.Mock; - const activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); - const cipherViews$ = new BehaviorSubject([]); - const pendingTasks$ = new BehaviorSubject([]); - const userId = "test-user-id" as UserId; + let activeAccount$: BehaviorSubject<{ id: string }>; + let cipherViews$: BehaviorSubject>; + let pendingTasks$: BehaviorSubject; beforeEach(async () => { setState = jest.fn().mockResolvedValue(undefined); - clearState = jest.fn().mockResolvedValue(undefined); - warning = jest.fn(); getAllDecryptedForUrl = jest.fn().mockResolvedValue([]); getTab = jest.fn(); addListener = jest.fn(); + activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); + cipherViews$ = new BehaviorSubject>([]); + pendingTasks$ = new BehaviorSubject([]); + jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener); jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab); service = new AtRiskCipherBadgeUpdaterService( - { setState, clearState } as unknown as BadgeService, + { setState } as unknown as BadgeService, { activeAccount$ } as unknown as AccountService, - { cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, - { warning } as unknown as LogService, - { pendingTasks$ } as unknown as TaskService, + { cipherViews$: () => cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, + { pendingTasks$: () => pendingTasks$ } as unknown as TaskService, ); await service.init(); @@ -55,30 +52,41 @@ describe("AtRiskCipherBadgeUpdaterService", () => { jest.restoreAllMocks(); }); + it("registers dynamic state function on init", () => { + expect(setState).toHaveBeenCalledWith("at-risk-cipher-badge", expect.any(Function)); + }); + it("clears the tab state when there are no ciphers and no pending tasks", async () => { - const tab = { id: 1 } as chrome.tabs.Tab; + const tab: Tab = { tabId: 1, url: "https://bitwarden.com" }; + const stateFunction = setState.mock.calls[0][1]; - await service["setTabState"](tab, userId, []); + const state = await firstValueFrom(stateFunction(tab)); - expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1"); + expect(state).toBeUndefined(); }); it("sets state when there are pending tasks for the tab", async () => { - const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab; - const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask]; + const tab: Tab = { tabId: 3, url: "https://bitwarden.com" }; + const stateFunction: BadgeStateFunction = setState.mock.calls[0][1]; + const pendingTasks: SecurityTask[] = [ + { + id: "task1", + cipherId: "cipher1", + type: SecurityTaskType.UpdateAtRiskCredential, + } as SecurityTask, + ]; + pendingTasks$.next(pendingTasks); getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]); - await service["setTabState"](tab, userId, pendingTasks); + const state = await firstValueFrom(stateFunction(tab)); - expect(setState).toHaveBeenCalledWith( - "at-risk-cipher-badge-3", - BadgeStatePriority.High, - { + expect(state).toEqual({ + priority: BadgeStatePriority.High, + state: { icon: BadgeIcon.Berry, text: Unset, backgroundColor: Unset, }, - 3, - ); + }); }); }); diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts index 47364958ad8..a06c208ebe2 100644 --- a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts @@ -1,26 +1,18 @@ -import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs"; +import { combineLatest, concatMap, map, of, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { BadgeService } from "../../platform/badge/badge.service"; import { BadgeIcon } from "../../platform/badge/icon"; import { BadgeStatePriority } from "../../platform/badge/priority"; import { Unset } from "../../platform/badge/state"; -import { BrowserApi } from "../../platform/browser/browser-api"; -const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`; +const StateName = "at-risk-cipher-badge"; export class AtRiskCipherBadgeUpdaterService { - private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>(); - private tabUpdated$ = new Subject(); - private tabRemoved$ = new Subject(); - private tabActivated$ = new Subject(); - private activeUserData$ = this.accountService.activeAccount$.pipe( filterOutNullish(), switchMap((user) => @@ -40,124 +32,36 @@ export class AtRiskCipherBadgeUpdaterService { private badgeService: BadgeService, private accountService: AccountService, private cipherService: CipherService, - private logService: LogService, private taskService: TaskService, - ) { - combineLatest({ - replaced: this.tabReplaced$, - activeUserData: this.activeUserData$, - }) - .pipe( - mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => { - await this.clearTabState(replaced.removedTabId); - await this.setTabState(replaced.addedTab, userId, pendingTasks); - }), - ) - .subscribe(() => {}); - - combineLatest({ - tab: this.tabActivated$, - activeUserData: this.activeUserData$, - }) - .pipe( - mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { - await this.setTabState(tab, userId, pendingTasks); - }), - ) - .subscribe(); - - combineLatest({ - tab: this.tabUpdated$, - activeUserData: this.activeUserData$, - }) - .pipe( - mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { - await this.setTabState(tab, userId, pendingTasks); - }), - ) - .subscribe(); - - this.tabRemoved$ - .pipe( - mergeMap(async (tabId) => { - await this.clearTabState(tabId); - }), - ) - .subscribe(); - } + ) {} init() { - BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => { - const newTab = await BrowserApi.getTab(addedTabId); - if (!newTab) { - this.logService.warning( - `Tab replaced event received but new tab not found (id: ${addedTabId})`, - ); - return; - } - - this.tabReplaced$.next({ - removedTabId, - addedTab: newTab, - }); - }); - - BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => { - if (changeInfo.url) { - this.tabUpdated$.next(tab); - } - }); - - BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => { - const tab = await BrowserApi.getTab(activeInfo.tabId); - if (!tab) { - this.logService.warning( - `Tab activated event received but tab not found (id: ${activeInfo.tabId})`, - ); - return; - } - - this.tabActivated$.next(tab); + this.badgeService.setState(StateName, (tab) => { + return this.activeUserData$.pipe( + concatMap(async ([userId, pendingTasks]) => { + const ciphers = tab.url + ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true) + : []; + + const hasPendingTasksForTab = pendingTasks.some((task) => + ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), + ); + + if (!hasPendingTasksForTab) { + return undefined; + } + + return { + priority: BadgeStatePriority.High, + state: { + icon: BadgeIcon.Berry, + // Unset text and background color to use default badge appearance + text: Unset, + backgroundColor: Unset, + }, + }; + }), + ); }); - - BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId)); - } - - /** Sets the pending task state for the tab */ - private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) { - if (!tab.id) { - this.logService.warning("Tab event received but tab id is undefined"); - return; - } - - const ciphers = tab.url - ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true) - : []; - - const hasPendingTasksForTab = pendingTasks.some((task) => - ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), - ); - - if (!hasPendingTasksForTab) { - await this.clearTabState(tab.id); - return; - } - - await this.badgeService.setState( - StateName(tab.id), - BadgeStatePriority.High, - { - icon: BadgeIcon.Berry, - // Unset text and background color to use default badge appearance - text: Unset, - backgroundColor: Unset, - }, - tab.id, - ); - } - - /** Clears the pending task state from a tab */ - private async clearTabState(tabId: number) { - await this.badgeService.clearState(StateName(tabId)); } } diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index e3ffa457e10..1c09b071e99 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -111,9 +111,6 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { web: "disk-local", }); -export const BADGE_MEMORY = new StateDefinition("badge", "memory", { - browser: "memory-large-object", -}); export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk"); export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CONFIG_DISK = new StateDefinition("config", "disk", {