diff --git a/README.md b/README.md index e0a1aa6d3..e3f292d39 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/background/main.ts b/background/main.ts index c7573e855..0a3ea23de 100644 --- a/background/main.ts +++ b/background/main.ts @@ -84,6 +84,7 @@ import { setShowAnalyticsNotification, setSelectedNetwork, setAutoLockInterval, + toggleNotifications, setShownDismissableItems, dismissableItemMarkedAsShown, } from "./redux-slices/ui" @@ -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 @@ -351,6 +353,11 @@ export default class Main extends BaseService { ledgerService, ) + const notificationsService = NotificationsService.create( + preferenceService, + islandService, + ) + const walletConnectService = isEnabled(FeatureFlags.SUPPORT_WALLET_CONNECT) ? WalletConnectService.create( providerBridgeService, @@ -406,6 +413,7 @@ export default class Main extends BaseService { await nftsService, await walletConnectService, await abilitiesService, + await notificationsService, ) } @@ -499,6 +507,11 @@ export default class Main extends BaseService { * 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: { @@ -609,6 +622,7 @@ export default class Main extends BaseService { this.nftsService.startService(), this.walletConnectService.startService(), this.abilitiesService.startService(), + this.notificationsService.startService(), ] await Promise.all(servicesToBeStarted) @@ -632,6 +646,7 @@ export default class Main extends BaseService { this.nftsService.stopService(), this.walletConnectService.stopService(), this.abilitiesService.stopService(), + this.notificationsService.stopService(), ] await Promise.all(servicesToBeStopped) @@ -690,6 +705,9 @@ export default class Main extends BaseService { signer: AccountSigner, lastAddressInAccount: boolean, ): Promise { + // 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) { @@ -1834,6 +1852,17 @@ export default class Main extends BaseService { }, ) + uiSliceEmitter.on( + "shouldShowNotifications", + async (shouldShowNotifications: boolean) => { + const isPermissionGranted = + await this.preferenceService.setShouldShowNotifications( + shouldShowNotifications, + ) + this.store.dispatch(toggleNotifications(isPermissionGranted)) + }, + ) + uiSliceEmitter.on( "updateAnalyticsPreferences", async (analyticsPreferences: Partial) => { diff --git a/background/redux-slices/ui.ts b/background/redux-slices/ui.ts index 394a67e8f..ec488ef3a 100644 --- a/background/redux-slices/ui.ts +++ b/background/redux-slices/ui.ts @@ -16,6 +16,7 @@ export const defaultSettings = { hideDust: false, defaultWallet: false, showTestNetworks: false, + showNotifications: undefined, collectAnalytics: false, showAnalyticsNotification: false, showUnverifiedAssets: false, @@ -34,6 +35,7 @@ export type UIState = { hideDust: boolean defaultWallet: boolean showTestNetworks: boolean + showNotifications?: boolean collectAnalytics: boolean showAnalyticsNotification: boolean showUnverifiedAssets: boolean @@ -57,6 +59,7 @@ export type Events = { newSelectedAccountSwitched: AddressOnNetwork userActivityEncountered: AddressOnNetwork newSelectedNetwork: EVMNetwork + shouldShowNotifications: boolean updateAnalyticsPreferences: Partial addCustomNetworkResponse: [string, boolean] updateAutoLockInterval: number @@ -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 }, @@ -226,6 +235,7 @@ export const { toggleUseFlashbots, setShowAnalyticsNotification, toggleHideBanners, + toggleNotifications, setSelectedAccount, setSnackbarMessage, setDefaultWallet, @@ -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 () => { @@ -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, diff --git a/background/services/notifications/index.ts b/background/services/notifications/index.ts new file mode 100644 index 000000000..108a80ed3 --- /dev/null +++ b/background/services/notifications/index.ts @@ -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) + +/** + * 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 { + 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, Promise] + > = async (preferenceService, islandService) => + new this(await preferenceService, await islandService) + + private constructor( + private preferenceService: PreferenceService, + private islandService: IslandService, + ) { + super() + } + + protected override async internalStartService(): Promise { + 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 { + // 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, + }) + } +} diff --git a/background/services/preferences/db.ts b/background/services/preferences/db.ts index ce12f3877..831b1d8a7 100644 --- a/background/services/preferences/db.ts +++ b/background/services/preferences/db.ts @@ -64,6 +64,7 @@ export type Preferences = { accountSignersSettings: AccountSignerSettings[] analytics: AnalyticsPreferences autoLockInterval: UNIXTime + shouldShowNotifications: boolean } /** @@ -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) => { + 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 @@ -452,6 +463,18 @@ export class PreferenceDatabase extends Dexie { }) } + async setShouldShowNotifications(newValue: boolean): Promise { + await this.preferences + .toCollection() + .modify((storedPreferences: Preferences) => { + const update: Partial = { + shouldShowNotifications: newValue, + } + + Object.assign(storedPreferences, update) + }) + } + async upsertAnalyticsPreferences( analyticsPreferences: Partial, ): Promise { diff --git a/background/services/preferences/defaults.ts b/background/services/preferences/defaults.ts index 69e32d342..2c5f5b425 100644 --- a/background/services/preferences/defaults.ts +++ b/background/services/preferences/defaults.ts @@ -34,6 +34,7 @@ const defaultPreferences = { hasDefaultOnBeenTurnedOn: false, }, autoLockInterval: DEFAULT_AUTOLOCK_INTERVAL, + shouldShowNotifications: false, } export default defaultPreferences diff --git a/background/services/preferences/index.ts b/background/services/preferences/index.ts index 774af1102..300757a6f 100644 --- a/background/services/preferences/index.ts +++ b/background/services/preferences/index.ts @@ -113,6 +113,7 @@ interface Events extends ServiceLifecycleEvents { updatedSignerSettings: AccountSignerSettings[] updateAutoLockInterval: UNIXTime dismissableItemMarkedAsShown: DismissableItem + setNotificationsPermission: boolean } /* @@ -264,6 +265,34 @@ export default class PreferenceService extends BaseService { this.emitter.emit("updateAnalyticsPreferences", analytics) } + async getShouldShowNotifications(): Promise { + return (await this.db.getPreferences()).shouldShowNotifications + } + + async setShouldShowNotifications(shouldShowNotifications: boolean) { + const permissionRequest: Promise = new Promise((resolve) => { + if (shouldShowNotifications) { + chrome.permissions.request( + { + permissions: ["notifications"], + }, + (granted) => { + resolve(granted) + }, + ) + } else { + resolve(false) + } + }) + + return permissionRequest.then(async (granted) => { + await this.db.setShouldShowNotifications(granted) + this.emitter.emit("setNotificationsPermission", granted) + + return granted + }) + } + async getAccountSignerSettings(): Promise { return this.db.getAccountSignerSettings() } diff --git a/manifest/manifest.json b/manifest/manifest.json index b81edb4e3..827e2f1a1 100644 --- a/manifest/manifest.json +++ b/manifest/manifest.json @@ -33,6 +33,7 @@ "default_popup": "popup.html" }, "permissions": ["alarms", "storage", "unlimitedStorage", "activeTab"], + "optional_permissions": ["notifications"], "background": { "persistent": true, "scripts": ["background.js", "background-ui.js"] diff --git a/ui/_locales/en/messages.json b/ui/_locales/en/messages.json index b9cada0d7..d9fd7129e 100644 --- a/ui/_locales/en/messages.json +++ b/ui/_locales/en/messages.json @@ -559,6 +559,7 @@ }, "mainMenu": "Settings", "signing": "Signing", + "showNotifications": "Show Subscape notifications", "hideSmallAssetBalance": "Hide asset balances under {{sign}}{{amount}}", "setAsDefault": "Use Taho as default wallet", "enableTestNetworks": "Show testnet networks", diff --git a/ui/components/Wallet/WalletSubscapeLink.tsx b/ui/components/Wallet/WalletSubscapeLink.tsx index c5988d40f..2d10738d1 100644 --- a/ui/components/Wallet/WalletSubscapeLink.tsx +++ b/ui/components/Wallet/WalletSubscapeLink.tsx @@ -1,17 +1,30 @@ import React, { ReactElement, useState } from "react" import classNames from "classnames" +import { useDispatch, useSelector } from "react-redux" +import { + selectShowNotifications, + setShouldShowNotifications, +} from "@tallyho/tally-background/redux-slices/ui" import SharedIcon from "../Shared/SharedIcon" export default function WalletSubspaceLink(): ReactElement { + const dispatch = useDispatch() + const shouldShowNotifications = useSelector(selectShowNotifications) const [isIconOnly, setIsIconOnly] = useState(true) + const onClick = () => { + if (!shouldShowNotifications) { + dispatch(setShouldShowNotifications(true)) + } + + window.open("https://app.taho.xyz/", "_blank")?.focus() + } + return (