Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Do notify: Set up base NotificationService #3666

Merged
merged 13 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
37 changes: 33 additions & 4 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 Expand Up @@ -2016,14 +2045,14 @@ export default class Main extends BaseService<never> {
runtime.onConnect.addListener((port) => {
if (port.name !== popupMonitorPortName) return

const { ui } = this.store.getState()

const openTime = Date.now()

const originalNetworkName =
this.store.getState().ui.selectedAccount.network.name
const originalNetworkName = ui.selectedAccount.network.name

port.onDisconnect.addListener(() => {
const networkNameAtClose =
this.store.getState().ui.selectedAccount.network.name
const networkNameAtClose = ui.selectedAccount.network.name
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this actually correct? I think UI state will be frozen to the one that we had when this.store.getState() was called - is this expected behaviour? (actually, I'm not sure because I haven't checked if getState gives us a copy of the state object or a state object that gets updated 😅)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally should not expect that getState is updated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a change like this to an existing behavior, let's make sure we have a commit with a commit message that explains the motivation for the change. A lot of the stuff in main that interacts with redux is very fiddly because it's syncing the lifecycle of the wallet popup with the background script.

This change looks like it can break the desired behavior, which is to check whether the network name changed during the lifetime of the popover. I don't see a clear reason for changing the existing code, and unfortunately the commit message has no details here, so it's hard to know if it's safe without doing a full debugger round.

Copy link
Contributor

@ioay ioay Nov 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have configured Prettier on file saving, so it was changed automatically. It doesn't change logic, so keep it as it is.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prettier settings we have here by default should not do it 🤔 and I'm pretty sure the logic was changed as we noticed above, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

omg:)) yees, mea culpa! I didn't notice my change here before! :)

this.analyticsService.sendAnalyticsEvent(AnalyticsEvent.UI_SHOWN, {
openTime: new Date(openTime).toISOString(),
closeTime: new Date().toISOString(),
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,
jagodarybacka marked this conversation as resolved.
Show resolved Hide resolved
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 showNotifications = createBackgroundAsyncThunk(
jagodarybacka marked this conversation as resolved.
Show resolved Hide resolved
"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
}
jagodarybacka marked this conversation as resolved.
Show resolved Hide resolved

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
} = {}

protected boundHandleNotificationClicks =
this.handleNotificationClicks.bind(this)

protected boundCleanUpNotificationClickHandler =
this.cleanUpNotificationClickHandler.bind(this)

jagodarybacka marked this conversation as resolved.
Show resolved Hide resolved
/*
* 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()

// 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 (isPermissionGranted) {
browser.notifications.onClicked.addListener(
this.boundHandleNotificationClicks,
)
browser.notifications.onClosed.addListener(
this.boundCleanUpNotificationClickHandler,
)
} else {
browser.notifications.onClicked.removeListener(
this.boundHandleNotificationClicks,
)
browser.notifications.onClosed.removeListener(
this.boundCleanUpNotificationClickHandler,
)
}
},
)

if (this.isPermissionGranted) {
browser.notifications.onClicked.addListener(
this.boundHandleNotificationClicks,
)
browser.notifications.onClosed.addListener(
this.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]()
jagodarybacka marked this conversation as resolved.
Show resolved Hide resolved
}

// 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