Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
9 changes: 6 additions & 3 deletions src/controllers/dapps/dapps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { Messenger } from '../../interfaces/messenger'
import { INetworksController } from '../../interfaces/network'
import { BlacklistedStatus, IPhishingController } from '../../interfaces/phishing'
import { IStorageController } from '../../interfaces/storage'
import { IUiController, View } from '../../interfaces/ui'
import { IUiController, View, isExtensionOverlayView } from '../../interfaces/ui'
import { UserRequest } from '../../interfaces/userRequest'
import {
formatDappName,
Expand Down Expand Up @@ -164,7 +164,7 @@ export class DappsController extends EventEmitter implements IDappsController {

this.#ui.uiEvent.on('removeView', (removedView: View) => {
if (
removedView.type === 'popup' &&
isExtensionOverlayView(removedView) &&
this.#shouldRetryFetchAndUpdate &&
this.#retryFetchAndUpdateAttempts < this.#retryFetchAndUpdateMaxAttempts
) {
Expand Down Expand Up @@ -271,7 +271,10 @@ export class DappsController extends EventEmitter implements IDappsController {
this.#shouldRetryFetchAndUpdate = true

// run the interval if the initial fetch failed while the extension is not in use
if (!this.#retryFetchAndUpdateAttempts && !this.#ui.views.some((v) => v.type === 'popup')) {
if (
!this.#retryFetchAndUpdateAttempts &&
!this.#ui.views.some((v) => isExtensionOverlayView(v))
) {
this.#retryFetchAndUpdateInterval.start()
} else {
this.#retryFetchAndUpdateInterval.stop()
Expand Down
4 changes: 2 additions & 2 deletions src/controllers/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import { ISwapAndBridgeController, SwapAndBridgeActiveRoute } from '@/interfaces
import { ITransactionManagerController } from '@/interfaces/transactionManager'
import { ITransferController } from '@/interfaces/transfer'
import { ITransfersScannerController } from '@/interfaces/transferScanner'
import { IUiController, UiManager, View } from '@/interfaces/ui'
import { IUiController, UiManager, View, isExtensionOverlayView } from '@/interfaces/ui'
import { BenzinUserRequest, CallsUserRequest } from '@/interfaces/userRequest'
import { IVerificationController } from '@/interfaces/verification'
import { getDefaultSelectedAccount } from '@/libs/account/account'
Expand Down Expand Up @@ -720,7 +720,7 @@ export class MainController extends EventEmitter implements IMainController {
})

this.ui.uiEvent.on('addView', async (view: View) => {
if (view.type === 'popup') await this.onPopupOpen(view.id)
if (isExtensionOverlayView(view)) await this.onPopupOpen(view.id)
})
}

Expand Down
138 changes: 92 additions & 46 deletions src/controllers/requests/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,10 +594,24 @@ export class RequestsController extends EventEmitter implements IRequestsControl
await this.closeRequestWindow()
}

// When the wallet runs in the Chrome side panel and the panel is open, the action
// requests are rendered inside the panel itself (over the dashboard) instead of a
// separate request window. A side-panel view only exists in `views` while the panel
// is open, so its presence is the signal to skip the window entirely.
get #isSidePanelOpen() {
return this.#ui.views.some((view) => view.type === 'side-panel')
}

async openRequestWindow(params?: OpenRequestWindowParams) {
const { skipFocus, baseWindowId } = params || {}
await this.#awaitPendingPromises()

if (this.#isSidePanelOpen) {
// The side panel reacts to `currentUserRequest` and renders the request in-place.
await this.forceEmitUpdate()
return
}

if (this.requestWindow.windowProps) {
if (!skipFocus) {
// Force-emitting here updates currentUserRequest on the FE before the window regains focus,
Expand Down Expand Up @@ -639,6 +653,12 @@ export class RequestsController extends EventEmitter implements IRequestsControl
async focusRequestWindow(params?: FocusWindowParams) {
await this.#awaitPendingPromises()

// In side-panel mode there is no separate window to focus; the panel already shows it.
if (this.#isSidePanelOpen) {
await this.forceEmitUpdate()
return
}

if (
!this.visibleUserRequests.length ||
!this.currentUserRequest ||
Expand Down Expand Up @@ -689,63 +709,89 @@ export class RequestsController extends EventEmitter implements IRequestsControl
await this.#handleRequestWindowClose(this.requestWindow.windowProps.id)
}

async #handleRequestWindowClose(winId: number) {
if (
winId === this.requestWindow.windowProps?.id ||
(!this.visibleUserRequests.length &&
this.currentUserRequest &&
this.requestWindow.windowProps)
) {
// Snapshot IDs synchronously before any awaits so requests that arrive
// during async operations below are not incorrectly bulk-rejected.
const requestIdsSnapshotAtClose = new Set(this.userRequests.map((r) => r.id))
async #onActiveRequestDismissed() {
const requestIdsSnapshotAtClose = new Set(this.userRequests.map((r) => r.id))

this.requestWindow.windowProps = null
this.requestWindow.loaded = false
this.requestWindow.pendingMessage = null
await this.#setCurrentUserRequest(null)
this.requestWindow.windowProps = null
this.requestWindow.loaded = false
this.requestWindow.pendingMessage = null
await this.#setCurrentUserRequest(null)

const callsCount = this.visibleUserRequests.reduce((acc, request) => {
if (request.kind !== 'calls') return acc
const callsCount = this.visibleUserRequests.reduce((acc, request) => {
if (request.kind !== 'calls') return acc

return acc + (request.signAccountOp.accountOp.calls?.length || 0)
}, 0)
return acc + (request.signAccountOp.accountOp.calls?.length || 0)
}, 0)

if (callsCount) {
await this.#ui.notification.create({
title: callsCount > 1 ? `${callsCount} transactions queued` : 'Transaction queued',
message: 'Queued pending transactions are available on your Dashboard.'
})
}
if (callsCount) {
await this.#ui.notification.create({
title: callsCount > 1 ? `${callsCount} transactions queued` : 'Transaction queued',
message: 'Queued pending transactions are available on your Dashboard.'
})
}

for (const r of this.userRequests) {
if (r.kind === 'walletAddEthereumChain') {
const chainId = r.meta.params[0].chainId
for (const r of this.userRequests) {
if (r.kind === 'walletAddEthereumChain') {
const chainId = r.meta.params[0].chainId

if (!chainId) continue
if (!chainId) continue

const network = this.#networks.networks.find((n) => n.chainId === BigInt(chainId))
if (network && !network.disabled) await this.resolveUserRequest(null, r.id)
}
const network = this.#networks.networks.find((n) => n.chainId === BigInt(chainId))
if (network && !network.disabled) await this.resolveUserRequest(null, r.id)
}
}

const userRequestsToRejectOnWindowClose = this.userRequests.filter(
(r) => r.kind !== 'calls' && !r.meta.keepRequestAlive && requestIdsSnapshotAtClose.has(r.id)
)
const userRequestsToRejectOnWindowClose = this.userRequests.filter(
(r) => r.kind !== 'calls' && !r.meta.keepRequestAlive && requestIdsSnapshotAtClose.has(r.id)
)

await this.rejectUserRequests(
ethErrors.provider.userRejectedRequest().message,
userRequestsToRejectOnWindowClose.map((r) => r.id),
// If the user closes a window and non-calls user requests exist,
// the window will reopen with the next request.
// For example: if the user has both a sign message and sign account op request,
// closing the window will reject the sign message request but immediately
// reopen the window for the sign account op request.
{ shouldOpenNextRequest: false }
)
await this.rejectUserRequests(
ethErrors.provider.userRejectedRequest().message,
userRequestsToRejectOnWindowClose.map((r) => r.id),
// If the user closes a window and non-calls user requests exist,
// the window will reopen with the next request.
// For example: if the user has both a sign message and sign account op request,
// closing the window will reject the sign message request but immediately
// reopen the window for the sign account op request.
{ shouldOpenNextRequest: false }
)

this.userRequestsWaitingAccountSwitch = []
this.emitUpdate()
this.userRequestsWaitingAccountSwitch = []
this.emitUpdate()
}

async dismissActiveRequest() {
await this.#awaitPendingPromises()
if (!this.currentUserRequest) return

try {
// In the side panel there is no window to close; apply the same queue semantics as
// closing the request window (keep calls queued, clear the active request).
if (this.#isSidePanelOpen) {
await this.#onActiveRequestDismissed()
return
}

if (this.requestWindow.windowProps) {
await this.closeRequestWindow()
}
} catch (err) {
this.emitError({
message: 'Failed to dismiss the active request. Please try again.',
level: 'major',
error: err as Error
})
}
}

async #handleRequestWindowClose(winId: number) {
if (
winId === this.requestWindow.windowProps?.id ||
(!this.visibleUserRequests.length &&
this.currentUserRequest &&
this.requestWindow.windowProps)
) {
await this.#onActiveRequestDismissed()
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/controllers/swapAndBridge/swapAndBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,19 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri
})

this.#ui.uiEvent.on('removeView', (view: View) => {
if (view.type === 'side-panel') {
if (isSwapAndBridge(view.currentRoute)) {
this.#isOnSwapAndBridgeRoute = false
this.updateQuoteInterval.stop()
}

if (this.sessionIds.includes('side-panel')) {
this.unloadScreen('side-panel', true)
}

return
}

if (!isSwapAndBridge(view.currentRoute)) return
this.#isOnSwapAndBridgeRoute = false
this.updateQuoteInterval.stop()
Expand Down
40 changes: 40 additions & 0 deletions src/controllers/transfer/transfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,46 @@ describe('Transfer Controller defaults logic', () => {
expect(transferController.areDefaultsSet).toBe(true)
})

test('should reset transfer state on side-panel removeView even when form is persisted', async () => {
const { transferController, uiCtrl, selectedAccountCtrl } = await prepareTest()

selectedAccountCtrl.portfolio = {
...selectedAccountCtrl.portfolio,
...getDefaultPortfolioState()
}

uiCtrl.addView({
id: 'side-panel',
type: 'side-panel',
currentRoute: 'dashboard',
isReady: false,
searchParams: {}
})
uiCtrl.updateView('side-panel', {
currentRoute: 'transfer',
isReady: true,
searchParams: {}
})

await transferController.update({
amount: '1',
addressState: {
fieldValue: PLACEHOLDER_RECIPIENT,
resolvedAddress: '',
resolvedAddressType: null,
isDomainResolving: false
}
})

uiCtrl.removeView('side-panel')

expect(transferController.transferSessionId).toBe(null)
expect(transferController.areDefaultsSet).toBe(false)
expect(transferController.selectedToken).toBeNull()
expect(transferController.amount).toBe('')
expect(transferController.recipientAddress).toBe('')
})

test('should reset transfer state on popup removeView when form is not persisted', async () => {
const { transferController, uiCtrl, selectedAccountCtrl } = await prepareTest()

Expand Down
4 changes: 3 additions & 1 deletion src/controllers/transfer/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,8 @@ export class TransferController extends EventEmitter implements ITransferControl
const isSameMode = this.isTopUp === nextIsTopUp
const hasNoSearchParams = Object.keys(searchParams || {}).length === 0

const shouldKeepExistingForm = isFormInitialized && isSameMode && hasNoSearchParams
const shouldKeepExistingForm =
isFormInitialized && isSameMode && hasNoSearchParams && view.type !== 'side-panel'

if (shouldKeepExistingForm) {
if (!this.areDefaultsSet) {
Expand Down Expand Up @@ -1235,6 +1236,7 @@ export class TransferController extends EventEmitter implements ITransferControl
// Always reset the session id
this.#currentTransferSessionId = null

// Popup keeps in-progress forms when closed; side panel should start fresh on reopen.
if (this.hasPersistedState && !isNavigateOut && viewType === 'popup') return

this.reset({ destroyAccountOp: true })
Expand Down
15 changes: 10 additions & 5 deletions src/controllers/ui/ui.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EventEmitter as UiEventEmitter } from 'events'

import { IEventEmitterRegistryController } from '../../interfaces/eventEmitter'
import { IUiController, UiManager, View } from '../../interfaces/ui'
import { IUiController, UiManager, View, isExtensionOverlayView } from '../../interfaces/ui'
import EventEmitter from '../eventEmitter/eventEmitter'

export class UiController extends EventEmitter implements IUiController {
Expand All @@ -15,6 +15,8 @@ export class UiController extends EventEmitter implements IUiController {

message: UiManager['message']

dispatchDappTabFocus?: UiManager['dispatchDappTabFocus']

constructor({
eventEmitterRegistry,
uiManager
Expand All @@ -28,14 +30,17 @@ export class UiController extends EventEmitter implements IUiController {
this.window = uiManager.window
this.notification = uiManager.notification
this.message = uiManager.message
this.dispatchDappTabFocus = uiManager.dispatchDappTabFocus
}

addView(view: View) {
const existingPopup = this.views.find((v) => v.type === 'popup')
const existingOverlay = this.views.find((v) => isExtensionOverlayView(v))

// if a popup already exists, just update its id and stop here
if (view.type === 'popup' && existingPopup) {
existingPopup.id = view.id
// if an overlay view already exists, just update its id and stop here
if (isExtensionOverlayView(view) && existingOverlay) {
existingOverlay.id = view.id
existingOverlay.type = view.type
if (!existingOverlay.isReady) this.uiEvent.emit('addView', view)
this.emitUpdate()
return
}
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export type StorageProps = {
lastDappsUpdateVersion: string | null
isPinned: boolean
isPrivacyModeEnabled: boolean
isSidePanelModeEnabled: boolean
phishing: {
version: number
updatedAt: number
Expand Down
9 changes: 8 additions & 1 deletion src/interfaces/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ export type IUiController = ControllerInterface<

export type View = {
id: string
type: 'request-window' | 'tab' | 'popup' | 'mobile'
type: 'request-window' | 'tab' | 'popup' | 'side-panel' | 'mobile'
currentRoute?: string
previousRoute?: string
isReady?: boolean
searchParams?: { [key: string]: string }
}

export const isExtensionOverlayView = (view: Pick<View, 'type'>) =>
view.type === 'popup' || view.type === 'side-panel'

export const isSidePanelView = (view: Pick<View, 'type'>) => view.type === 'side-panel'

export type UiManager = {
window: {
event: EventEmitter
Expand Down Expand Up @@ -50,6 +55,8 @@ export type UiManager = {
sendUiMessage: (params: {}) => void
sendNavigateMessage: (viewId: string, route: string, params: { [key: string]: any }) => void
}
// Extension-only: nudge a dapp tab after side-panel requests so React Query refetches.
dispatchDappTabFocus?: (targets: { tabId: number; windowId?: number }[]) => void
}

export type WindowId = number
Expand Down
Loading