Skip to content

Commit

Permalink
Do notify: Set up base NotificationService (#3666)
Browse files Browse the repository at this point in the history
This PR adds notification-related preference, service, optional manifest
permission,
and code to request the permission on Subscape banner click.

More to come!


-------------
UPDATE: 11/20/23
PR is rebased with the main branch. Force push was required because of
unverified commits.
To open a dialog window with a request notification you need to click
the link hidden in the Subscape bubble.

The first push notification will be added in #3667

Latest build:
[extension-builds-3666](https://github.com/tahowallet/extension/suites/18412947056/artifacts/1067032376)
(as of Wed, 22 Nov 2023 13:41:33 GMT).
  • Loading branch information
jagodarybacka authored Nov 22, 2023
2 parents 8d6acb3 + 9ba58d9 commit 9d01388
Show file tree
Hide file tree
Showing 11 changed files with 296 additions and 3 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ to only rebuild the Firefox extension on change:
$ yarn start --config-name firefox
# On change, rebuild the firefox and brave extensions but not others.
$ yarn start --config-name firefox --config-name brave
# On change, rebuild the chrome
$ yarn start --config-name chrome
```

### Note for some Linux distributions
Expand Down
29 changes: 29 additions & 0 deletions background/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
setShowAnalyticsNotification,
setSelectedNetwork,
setAutoLockInterval,
toggleNotifications,
setShownDismissableItems,
dismissableItemMarkedAsShown,
} from "./redux-slices/ui"
Expand Down Expand Up @@ -198,6 +199,7 @@ import { getPricePoint, getTokenPrices } from "./lib/prices"
import { makeFlashbotsProviderCreator } from "./services/chain/serial-fallback-provider"
import { AnalyticsPreferences, DismissableItem } from "./services/preferences"
import { newPricePoints } from "./redux-slices/prices"
import NotificationsService from "./services/notifications"

// This sanitizer runs on store and action data before serializing for remote
// redux devtools. The goal is to end up with an object that is directly
Expand Down Expand Up @@ -351,6 +353,11 @@ export default class Main extends BaseService<never> {
ledgerService,
)

const notificationsService = NotificationsService.create(
preferenceService,
islandService,
)

const walletConnectService = isEnabled(FeatureFlags.SUPPORT_WALLET_CONNECT)
? WalletConnectService.create(
providerBridgeService,
Expand Down Expand Up @@ -406,6 +413,7 @@ export default class Main extends BaseService<never> {
await nftsService,
await walletConnectService,
await abilitiesService,
await notificationsService,
)
}

Expand Down Expand Up @@ -499,6 +507,11 @@ export default class Main extends BaseService<never> {
* A promise to the Abilities service which takes care of fetching and storing abilities
*/
private abilitiesService: AbilitiesService,

/**
* A promise to the Notifications service which takes care of observing and delivering notifications
*/
private notificationsService: NotificationsService,
) {
super({
initialLoadWaitExpired: {
Expand Down Expand Up @@ -609,6 +622,7 @@ export default class Main extends BaseService<never> {
this.nftsService.startService(),
this.walletConnectService.startService(),
this.abilitiesService.startService(),
this.notificationsService.startService(),
]

await Promise.all(servicesToBeStarted)
Expand All @@ -632,6 +646,7 @@ export default class Main extends BaseService<never> {
this.nftsService.stopService(),
this.walletConnectService.stopService(),
this.abilitiesService.stopService(),
this.notificationsService.stopService(),
]

await Promise.all(servicesToBeStopped)
Expand Down Expand Up @@ -690,6 +705,9 @@ export default class Main extends BaseService<never> {
signer: AccountSigner,
lastAddressInAccount: boolean,
): Promise<void> {
// FIXME This whole method should be replaced with a call to
// FIXME signerService.removeAccount and an event emission that is
// FIXME observed by other services, either directly or indirectly.
this.store.dispatch(deleteAccount(address))

if (signer.type !== AccountType.ReadOnly && lastAddressInAccount) {
Expand Down Expand Up @@ -1834,6 +1852,17 @@ export default class Main extends BaseService<never> {
},
)

uiSliceEmitter.on(
"shouldShowNotifications",
async (shouldShowNotifications: boolean) => {
const isPermissionGranted =
await this.preferenceService.setShouldShowNotifications(
shouldShowNotifications,
)
this.store.dispatch(toggleNotifications(isPermissionGranted))
},
)

uiSliceEmitter.on(
"updateAnalyticsPreferences",
async (analyticsPreferences: Partial<AnalyticsPreferences>) => {
Expand Down
22 changes: 22 additions & 0 deletions background/redux-slices/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const defaultSettings = {
hideDust: false,
defaultWallet: false,
showTestNetworks: false,
showNotifications: undefined,
collectAnalytics: false,
showAnalyticsNotification: false,
showUnverifiedAssets: false,
Expand All @@ -34,6 +35,7 @@ export type UIState = {
hideDust: boolean
defaultWallet: boolean
showTestNetworks: boolean
showNotifications?: boolean
collectAnalytics: boolean
showAnalyticsNotification: boolean
showUnverifiedAssets: boolean
Expand All @@ -57,6 +59,7 @@ export type Events = {
newSelectedAccountSwitched: AddressOnNetwork
userActivityEncountered: AddressOnNetwork
newSelectedNetwork: EVMNetwork
shouldShowNotifications: boolean
updateAnalyticsPreferences: Partial<AnalyticsPreferences>
addCustomNetworkResponse: [string, boolean]
updateAutoLockInterval: number
Expand Down Expand Up @@ -116,6 +119,12 @@ const uiSlice = createSlice({
showAnalyticsNotification: false,
},
}),
toggleNotifications: (
immerState,
{ payload: showNotifications }: { payload: boolean },
) => {
immerState.settings.showNotifications = showNotifications
},
setShowAnalyticsNotification: (
state,
{ payload: showAnalyticsNotification }: { payload: boolean },
Expand Down Expand Up @@ -226,6 +235,7 @@ export const {
toggleUseFlashbots,
setShowAnalyticsNotification,
toggleHideBanners,
toggleNotifications,
setSelectedAccount,
setSnackbarMessage,
setDefaultWallet,
Expand All @@ -249,6 +259,13 @@ export const updateAnalyticsPreferences = createBackgroundAsyncThunk(
},
)

export const setShouldShowNotifications = createBackgroundAsyncThunk(
"ui/showNotifications",
async (shouldShowNotifications: boolean) => {
await emitter.emit("shouldShowNotifications", shouldShowNotifications)
},
)

export const deleteAnalyticsData = createBackgroundAsyncThunk(
"ui/deleteAnalyticsData",
async () => {
Expand Down Expand Up @@ -429,6 +446,11 @@ export const selectCollectAnalytics = createSelector(
(settings) => settings?.collectAnalytics,
)

export const selectShowNotifications = createSelector(
selectSettings,
(settings) => settings?.showNotifications,
)

export const selectHideBanners = createSelector(
selectSettings,
(settings) => settings?.hideBanners,
Expand Down
154 changes: 154 additions & 0 deletions background/services/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { uniqueId } from "lodash"
import BaseService from "../base"
import IslandService from "../island"
import PreferenceService from "../preferences"
import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types"

type Events = ServiceLifecycleEvents & {
notificationDisplayed: string
notificationSuppressed: string
}

type NotificationClickHandler = (() => Promise<void>) | (() => void)

/**
* The NotificationService manages all notifications for the extension. It is
* charged both with managing the actual notification lifecycle (notification
* delivery, dismissal, and reaction to clicks) and delivery (i.e., responding
* to user preferences to deliver vs not deliver notifications), but also is
* charged with actually creating the notifications themselves.
*
* Adding a new notification should involve connecting the appropriate event in
* another service to a method in NotificationService that will generate the
* corresponding notification. In that way, the NotificationService is more part
* of the UI aspect of the extension than the background aspect, as it decides
* on and creates user-visible content directly.
*/
export default class NotificationsService extends BaseService<Events> {
private isPermissionGranted: boolean | null = null

private clickHandlers: {
[notificationId: string]: NotificationClickHandler
} = {}

/*
* Create a new NotificationsService. The service isn't initialized until
* startService() is called and resolved.
*/
static create: ServiceCreatorFunction<
Events,
NotificationsService,
[Promise<PreferenceService>, Promise<IslandService>]
> = async (preferenceService, islandService) =>
new this(await preferenceService, await islandService)

private constructor(
private preferenceService: PreferenceService,
private islandService: IslandService,
) {
super()
}

protected override async internalStartService(): Promise<void> {
await super.internalStartService()

const boundHandleNotificationClicks =
this.handleNotificationClicks.bind(this)

const boundCleanUpNotificationClickHandler =
this.cleanUpNotificationClickHandler.bind(this)

// Preference and listener setup.
// NOTE: Below, we assume if we got `shouldShowNotifications` as true, the
// browser notifications permission has been granted. The preferences service
// does guard this, but if that ends up not being true, browser.notifications
// will be undefined and all of this will explode.
this.isPermissionGranted =
await this.preferenceService.getShouldShowNotifications()

this.preferenceService.emitter.on(
"setNotificationsPermission",
(isPermissionGranted) => {
if (typeof browser !== "undefined") {
if (isPermissionGranted) {
browser.notifications.onClicked.addListener(
boundHandleNotificationClicks,
)
browser.notifications.onClosed.addListener(
boundCleanUpNotificationClickHandler,
)
} else {
browser.notifications.onClicked.removeListener(
boundHandleNotificationClicks,
)
browser.notifications.onClosed.removeListener(
boundCleanUpNotificationClickHandler,
)
}
}
},
)

if (this.isPermissionGranted) {
browser.notifications.onClicked.addListener(boundHandleNotificationClicks)
browser.notifications.onClosed.addListener(
boundCleanUpNotificationClickHandler,
)
}

/*
* FIXME add below
this.islandService.emitter.on("xpDropped", this.notifyXpDrop.bind(this))
*/
}

// TODO: uncomment when the XP drop is ready
// protected async notifyDrop(/* xpInfos: XpInfo[] */): Promise<void> {
// const callback = () => {
// browser.tabs.create({
// url: "dapp url for realm claim, XpInfo must include realm id, ideally some way to communicate if the address is right as well",
// })
// }
// this.notify({ callback })
// }

// Fires the click handler for the given notification id.
protected handleNotificationClicks(notificationId: string): void {
this.clickHandlers?.[notificationId]()
}

// Clears the click handler for the given notification id.
protected cleanUpNotificationClickHandler(notificationId: string): void {
delete this.clickHandlers?.[notificationId]
}

/**
* Issues a notification with the given title, message, and context message.
* The click action, if specified, will be fired when the user clicks on the
* notification.
*/
protected async notify({
title = "",
message = "",
contextMessage = "",
callback,
}: {
title?: string
message?: string
contextMessage?: string
callback?: () => void
}) {
if (!this.isPermissionGranted) {
return
}
const notificationId = uniqueId("notification-")

await browser.notifications.create(notificationId, {
type: "basic",
title,
message,
contextMessage,
isClickable: !!callback,
})
}
}
23 changes: 23 additions & 0 deletions background/services/preferences/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export type Preferences = {
accountSignersSettings: AccountSignerSettings[]
analytics: AnalyticsPreferences
autoLockInterval: UNIXTime
shouldShowNotifications: boolean
}

/**
Expand Down Expand Up @@ -423,6 +424,16 @@ export class PreferenceDatabase extends Dexie {
}),
)

// Add default notifications and set as default off.
this.version(21).upgrade((tx) =>
tx
.table("preferences")
.toCollection()
.modify((storedPreferences: Omit<Preferences, "showNotifications">) => {
Object.assign(storedPreferences, { showNotifications: false })
}),
)

// This is the old version for populate
// https://dexie.org/docs/Dexie/Dexie.on.populate-(old-version)
// The this does not behave according the new docs, but works
Expand Down Expand Up @@ -452,6 +463,18 @@ export class PreferenceDatabase extends Dexie {
})
}

async setShouldShowNotifications(newValue: boolean): Promise<void> {
await this.preferences
.toCollection()
.modify((storedPreferences: Preferences) => {
const update: Partial<Preferences> = {
shouldShowNotifications: newValue,
}

Object.assign(storedPreferences, update)
})
}

async upsertAnalyticsPreferences(
analyticsPreferences: Partial<AnalyticsPreferences>,
): Promise<void> {
Expand Down
1 change: 1 addition & 0 deletions background/services/preferences/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const defaultPreferences = {
hasDefaultOnBeenTurnedOn: false,
},
autoLockInterval: DEFAULT_AUTOLOCK_INTERVAL,
shouldShowNotifications: false,
}

export default defaultPreferences
Loading

0 comments on commit 9d01388

Please sign in to comment.