diff --git a/src/components/DappBrowser/BrowserContext.tsx b/src/components/DappBrowser/BrowserContext.tsx index 73ed0be8068..50545c3a208 100644 --- a/src/components/DappBrowser/BrowserContext.tsx +++ b/src/components/DappBrowser/BrowserContext.tsx @@ -8,6 +8,7 @@ import Animated, { SharedValue, runOnJS, runOnUI, + useAnimatedReaction, useAnimatedRef, useScrollViewOffset, useSharedValue, @@ -15,7 +16,7 @@ import Animated, { } from 'react-native-reanimated'; import WebView from 'react-native-webview'; import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; -import { generateUniqueId } from './utils'; +import { generateUniqueId, generateUniqueIdWorklet } from './utils'; interface BrowserTabViewProgressContextType { tabViewProgress: SharedValue | undefined; @@ -39,11 +40,12 @@ interface BrowserContextType { activeTabIndex: number; activeTabRef: React.MutableRefObject; animatedActiveTabIndex: SharedValue | undefined; - closeTab: (tabId: string) => void; + closeTabWorklet: (tabId: string, tabIndex: number) => void; + currentlyOpenTabIds: SharedValue | undefined; goBack: () => void; goForward: () => void; loadProgress: SharedValue | undefined; - newTab: () => void; + newTabWorklet: () => void; onRefresh: () => void; searchInputRef: React.RefObject; searchViewProgress: SharedValue | undefined; @@ -65,6 +67,14 @@ export interface TabState { logoUrl?: string | null; } +type TabOperationType = 'newTab' | 'closeTab'; + +interface TabOperation { + type: TabOperationType; + tabId: string; + newActiveIndex: number | undefined; +} + export const RAINBOW_HOME = 'RAINBOW_HOME'; const DEFAULT_TAB_STATE: TabState[] = [ @@ -83,16 +93,17 @@ const DEFAULT_BROWSER_CONTEXT: BrowserContextType = { activeTabIndex: 0, activeTabRef: { current: null }, animatedActiveTabIndex: undefined, - closeTab: () => { + closeTabWorklet: () => { return; }, + currentlyOpenTabIds: undefined, goBack: () => { return; }, goForward: () => { return; }, - newTab: () => { + newTabWorklet: () => { return; }, onRefresh: () => { @@ -128,8 +139,6 @@ export const useBrowserContext = () => useContext(BrowserContext); const tabStateStore = new MMKV(); -const EMPTY_TAB_STATE: TabState[] = []; - export const BrowserContextProvider = ({ children }: { children: React.ReactNode }) => { const [activeTabIndex, setActiveTabIndex] = useState(0); const [tabStates, setTabStates] = useMMKVObject('tabStateStorage', tabStateStore); @@ -138,7 +147,7 @@ export const BrowserContextProvider = ({ children }: { children: React.ReactNode (newState: Partial, tabId?: string) => { if (!tabStates) return; - const tabIndex = tabId ? tabStates.findIndex(tab => tab.uniqueId === tabId) : activeTabIndex; + const tabIndex = tabId ? tabStates?.findIndex(tab => tab.uniqueId === tabId) : activeTabIndex; if (tabIndex === -1) return; if (isEqual(tabStates[tabIndex], newState)) return; @@ -162,16 +171,40 @@ export const BrowserContextProvider = ({ children }: { children: React.ReactNode const animatedActiveTabIndex = useSharedValue(0); const { tabViewProgress } = useBrowserTabViewProgressContext(); + // We use the currentlyOpenTabIds shared value as an always-up-to-date source of truth for which + // tabs are open at any given moment, inclusive of any pending tab operations. This ensures that + // a stale version of tabStates is never used when multiple tabs are closed or created quickly. + // This value is updated in real time when a 'tabClose' or 'newTab' operation initiates. + const currentlyOpenTabIds = useSharedValue(tabStates?.map(tab => tab.uniqueId) || []); + + const tabOperationQueue = useSharedValue([]); + const shouldBlockOperationQueue = useSharedValue(false); + const toggleTabViewWorklet = useCallback( (activeIndex?: number) => { 'worklet'; const willTabViewBecomeVisible = !tabViewVisible.value; const tabIndexProvided = activeIndex !== undefined; - if (tabIndexProvided && !willTabViewBecomeVisible) { + if (!willTabViewBecomeVisible && tabIndexProvided) { animatedActiveTabIndex.value = activeIndex; runOnJS(setActiveTabIndex)(activeIndex); + } else if (!willTabViewBecomeVisible && animatedActiveTabIndex.value < 0) { + // If the index is negative here, it indicates that a previously active tab was closed, + // and the tab view was then exited via the "Done" button. We can identify the correct + // tab to make active now by flipping the current index back to a positive number. + // Note: The code that sets this negative index can be found in closeTabWorklet(). + const indexToMakeActive = Math.abs(animatedActiveTabIndex.value); + + if (indexToMakeActive < currentlyOpenTabIds.value.length) { + animatedActiveTabIndex.value = indexToMakeActive; + runOnJS(setActiveTabIndex)(indexToMakeActive); + } else { + animatedActiveTabIndex.value = currentlyOpenTabIds.value.length - 1; + runOnJS(setActiveTabIndex)(currentlyOpenTabIds.value.length - 1); + } } + if (tabViewProgress !== undefined) { tabViewProgress.value = willTabViewBecomeVisible ? withSpring(100, SPRING_CONFIGS.browserTabTransition) @@ -180,62 +213,220 @@ export const BrowserContextProvider = ({ children }: { children: React.ReactNode tabViewVisible.value = willTabViewBecomeVisible; }, - [animatedActiveTabIndex, tabViewProgress, tabViewVisible] + [animatedActiveTabIndex, currentlyOpenTabIds, tabViewProgress, tabViewVisible] ); - const newTab = useCallback(() => { - const newTabToAdd = { - canGoBack: false, - canGoForward: false, - uniqueId: generateUniqueId(), - url: RAINBOW_HOME, - }; - - if (!tabStates) { - setTabStates([newTabToAdd]); - runOnUI(toggleTabViewWorklet)(0); - } else { - const updatedTabs = [...tabStates, newTabToAdd]; - setTabStates(updatedTabs); - runOnUI(toggleTabViewWorklet)(updatedTabs.length - 1); - } - }, [setTabStates, tabStates, toggleTabViewWorklet]); + const requestTabOperationsWorklet = useCallback( + (operations: TabOperation | TabOperation[]) => { + 'worklet'; + if (Array.isArray(operations)) { + tabOperationQueue.modify(currentQueue => { + currentQueue.push(...operations); + return currentQueue; + }); + } else { + tabOperationQueue.modify(currentQueue => { + currentQueue.push(operations); + return currentQueue; + }); + } + }, + [tabOperationQueue] + ); - const closeTab = useCallback( - (tabId: string) => { - if (!tabStates) return; + const setTabStatesThenUnblockQueue = useCallback( + (updatedTabStates: TabState[], shouldToggleTabView?: boolean, indexToMakeActive?: number) => { + setTabStates(updatedTabStates); - const tabIndex = tabStates.findIndex(tab => tab.uniqueId === tabId); - if (tabIndex === -1) return; + if (shouldToggleTabView) { + runOnUI(toggleTabViewWorklet)(indexToMakeActive); + } else if (indexToMakeActive !== undefined) { + setActiveTabIndex(indexToMakeActive); + } - const isActiveTab = tabIndex === activeTabIndex; - const isLastTab = tabIndex === tabStates.length - 1; - const hasNextTab = tabIndex < tabStates.length - 1; - - let newActiveTabIndex = activeTabIndex; - - if (isActiveTab) { - if (isLastTab && tabIndex === 0) { - setActiveTabIndex(0); - animatedActiveTabIndex.value = 0; - setTabStates(EMPTY_TAB_STATE); - newTab(); - return; - } else if (isLastTab && tabIndex > 0) { - newActiveTabIndex = tabIndex - 1; - } else if (hasNextTab) { - newActiveTabIndex = tabIndex; + shouldBlockOperationQueue.value = false; + }, + [setTabStates, shouldBlockOperationQueue, toggleTabViewWorklet] + ); + + const processOperationQueueWorklet = useCallback(() => { + 'worklet'; + if (shouldBlockOperationQueue.value || tabOperationQueue.value.length === 0) { + return; + } + + shouldBlockOperationQueue.value = true; + + let shouldToggleTabView = false; + let newActiveIndex: number | undefined = animatedActiveTabIndex.value; + + tabOperationQueue.modify(currentQueue => { + const newTabStates = tabStates || []; + // Process closeTab operations from oldest to newest + for (let i = 0; i < currentQueue.length; i++) { + const operation = currentQueue[i]; + if (operation.type === 'closeTab') { + const indexToClose = newTabStates.findIndex(tab => tab.uniqueId === operation.tabId); + if (indexToClose !== -1) { + newTabStates.splice(indexToClose, 1); + // Check to ensure we are setting a valid active tab index + if (operation.newActiveIndex === undefined) { + newActiveIndex = undefined; + } else { + const requestedNewActiveIndex = Math.abs(operation.newActiveIndex); + const isRequestedIndexValid = requestedNewActiveIndex >= 0 && requestedNewActiveIndex < currentlyOpenTabIds.value.length; + if (isRequestedIndexValid) { + newActiveIndex = operation.newActiveIndex; + } else { + // Make the last tab active if the requested index is not found + // (Negative to avoid immediately making the tab active - see notes in closeTabWorklet()) + newActiveIndex = -(currentlyOpenTabIds.value.length - 1); + } + } + } else { + // ⚠️ TODO: Add logging here to report any time a tab close operation was registered for a + // nonexistent tab (should never happen) + } + // Remove the operation from the queue after processing + currentQueue.splice(i, 1); + } + } + // Then process newTab operations from oldest to newest + for (let i = 0; i < currentQueue.length; i++) { + const operation = currentQueue[i]; + if (operation.type === 'newTab') { + // Check to ensure the tabId exists in currentlyOpenTabIds before creating the tab + const indexForNewTab = currentlyOpenTabIds.value.findIndex(tabId => tabId === operation.tabId); + if (indexForNewTab !== -1) { + const newTab = { + canGoBack: false, + canGoForward: false, + uniqueId: operation.tabId, + url: RAINBOW_HOME, + }; + newTabStates.push(newTab); + shouldToggleTabView = true; + newActiveIndex = indexForNewTab; + } else { + // ⚠️ TODO: Add logging here to report any time a new tab operation is given a nonexistent + // tabId (should never happen) + } + // Remove the operation from the queue after processing + currentQueue.splice(i, 1); } - } else if (tabIndex < activeTabIndex) { - newActiveTabIndex = activeTabIndex - 1; } - const updatedTabs = [...tabStates.slice(0, tabIndex), ...tabStates.slice(tabIndex + 1)]; - setTabStates(updatedTabs); - setActiveTabIndex(newActiveTabIndex); - animatedActiveTabIndex.value = newActiveTabIndex; + if (newActiveIndex !== undefined && (tabViewVisible.value || newActiveIndex >= 0)) { + animatedActiveTabIndex.value = newActiveIndex; + } else { + const currentActiveIndex = tabViewVisible?.value ? Math.abs(animatedActiveTabIndex.value) : animatedActiveTabIndex.value; + const isCurrentIndexValid = currentActiveIndex >= 0 && currentActiveIndex < currentlyOpenTabIds.value.length; + const indexToSet = isCurrentIndexValid ? animatedActiveTabIndex.value : currentlyOpenTabIds.value.length - 1; + newActiveIndex = indexToSet; + animatedActiveTabIndex.value = indexToSet; + } + + runOnJS(setTabStatesThenUnblockQueue)( + newTabStates, + shouldToggleTabView, + // If a new tab was created, the new tab will be the last tab and it should be made active now. + // We've already set the animatedActiveTabIndex to the correct index above, but the JS-side + // activeTabIndex still needs to be set, so we pass it along to setTabStatesThenUnblockQueue(). + newActiveIndex + ); + + // Return the remaining queue after processing, which should be empty + return currentQueue; + }); + }, [ + animatedActiveTabIndex, + currentlyOpenTabIds, + setTabStatesThenUnblockQueue, + shouldBlockOperationQueue, + tabOperationQueue, + tabStates, + tabViewVisible, + ]); + + const newTabWorklet = useCallback(() => { + 'worklet'; + const tabIdsInStates = new Set(tabStates?.map(state => state.uniqueId)); + const isNewTabOperationPending = + tabOperationQueue.value.some(operation => operation.type === 'newTab') || + currentlyOpenTabIds.value.some(tabId => !tabIdsInStates.has(tabId)); + + // The first check is mainly to guard against an edge case that happens when the new tab button is + // pressed just after the last tab is closed, but before a new blank tab has opened programatically, + // which results in two tabs being created when the user most certainly only wanted one. + if (!isNewTabOperationPending && (tabViewVisible.value || currentlyOpenTabIds.value.length === 0)) { + const tabIdForNewTab = generateUniqueIdWorklet(); + const newActiveIndex = currentlyOpenTabIds.value.length - 1; + + currentlyOpenTabIds.modify(value => { + value.push(tabIdForNewTab); + return value; + }); + requestTabOperationsWorklet({ type: 'newTab', tabId: tabIdForNewTab, newActiveIndex }); + } + }, [currentlyOpenTabIds, requestTabOperationsWorklet, tabOperationQueue, tabStates, tabViewVisible]); + + const closeTabWorklet = useCallback( + (tabId: string, tabIndex: number) => { + 'worklet'; + + // Note: The closed tab is removed from currentlyOpenTabIds ahead of time in BrowserTab as soon + // as the tab is swiped away, so that any operations applied between the time the swipe gesture + // is released and the time the tab is actually closed are aware of the pending deletion of the + // tab. The logic below assumes that the tab has already been removed from currentlyOpenTabIds. + + const currentActiveIndex = Math.abs(animatedActiveTabIndex.value); + const isActiveTab = tabIndex === currentActiveIndex; + const isLastRemainingTab = currentlyOpenTabIds.value.length === 0; + // ⬆️ These two ⬇️ checks account for the tab already being removed from currentlyOpenTabIds + const tabExistsAtNextIndex = tabIndex < currentlyOpenTabIds.value.length; + + let newActiveIndex: number | undefined = currentActiveIndex; + + if (isLastRemainingTab) { + requestTabOperationsWorklet({ type: 'closeTab', tabId, newActiveIndex: 0 }); + newTabWorklet(); + return; + } else if (isActiveTab) { + newActiveIndex = tabExistsAtNextIndex ? tabIndex : tabIndex - 1; + } else if (tabIndex < currentActiveIndex) { + newActiveIndex = currentActiveIndex - 1; + } + + const tabIdsInStates = new Set(tabStates?.map(tab => tab.uniqueId)); + const isNewTabOperationPending = + tabOperationQueue.value.some(operation => operation.type === 'newTab') || + currentlyOpenTabIds.value.some(tabId => !tabIdsInStates.has(tabId)); + + if (!isNewTabOperationPending && tabViewVisible.value) { + // To avoid unfreezing a WebView every time a tab is closed, we set the active tab index to the + // negative index of the tab that should become active if the tab view is exited via the "Done" + // button. Then in toggleTabViewWorklet(), if no new active index is provided and the active tab + // index is negative, we handling using the negative index to set the correct active tab index. + newActiveIndex = -newActiveIndex; + } else { + newActiveIndex = undefined; + } + + requestTabOperationsWorklet({ type: 'closeTab', tabId, newActiveIndex }); }, - [activeTabIndex, animatedActiveTabIndex, newTab, setTabStates, tabStates] + [animatedActiveTabIndex, currentlyOpenTabIds, newTabWorklet, requestTabOperationsWorklet, tabOperationQueue, tabStates, tabViewVisible] + ); + + useAnimatedReaction( + () => ({ + operations: tabOperationQueue.value, + shouldBlock: shouldBlockOperationQueue.value, + }), + (current, previous) => { + if (previous && current !== previous && current.operations.length > 0) { + processOperationQueueWorklet(); + } + } ); const goBack = useCallback(() => { @@ -262,11 +453,12 @@ export const BrowserContextProvider = ({ children }: { children: React.ReactNode activeTabIndex, activeTabRef, animatedActiveTabIndex, - closeTab, + closeTabWorklet, + currentlyOpenTabIds, goBack, goForward, loadProgress, - newTab, + newTabWorklet, onRefresh, searchViewProgress, searchInputRef, diff --git a/src/components/DappBrowser/BrowserTab.tsx b/src/components/DappBrowser/BrowserTab.tsx index 46f18ae9517..5aedf139f03 100644 --- a/src/components/DappBrowser/BrowserTab.tsx +++ b/src/components/DappBrowser/BrowserTab.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { FasterImageView, ImageOptions } from '@candlefinance/faster-image'; -import { Box, globalColors, useColorMode } from '@/design-system'; +import { globalColors, useColorMode } from '@/design-system'; import { useDimensions } from '@/hooks'; import React, { useCallback, useLayoutEffect, useRef } from 'react'; import { StyleSheet, View } from 'react-native'; @@ -11,8 +11,8 @@ import { TapGestureHandlerGestureEvent, } from 'react-native-gesture-handler'; import Animated, { + FadeIn, convertToRGBA, - dispatchCommand, interpolate, isColor, runOnJS, @@ -44,7 +44,7 @@ import RNFS from 'react-native-fs'; import { WebViewEvent } from 'react-native-webview/lib/WebViewTypes'; import { appMessenger } from '@/browserMessaging/AppMessenger'; import { IS_ANDROID, IS_DEV, IS_IOS } from '@/env'; -import CloseTabButton, { X_BUTTON_PADDING, X_BUTTON_SIZE } from './CloseTabButton'; +import { CloseTabButton, X_BUTTON_PADDING, X_BUTTON_SIZE } from './CloseTabButton'; import DappBrowserWebview from './DappBrowserWebview'; import Homepage from './Homepage'; import { handleProviderRequestApp } from './handleProviderRequest'; @@ -184,7 +184,8 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje activeTabIndex, activeTabRef, animatedActiveTabIndex, - closeTab, + closeTabWorklet, + currentlyOpenTabIds, loadProgress, scrollViewRef, scrollViewOffset, @@ -203,10 +204,6 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje const webViewRef = useRef(null); const viewShotRef = useRef(null); - const panRef = useRef(); - const tapRef = useRef(); - const tapCloseRef = useRef(); - // ⚠️ TODO const gestureScale = useSharedValue(1); const gestureX = useSharedValue(0); @@ -223,16 +220,54 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje const tabUrl = tabStates?.[tabIndex]?.url; const isActiveTab = activeTabIndex === tabIndex; - const multipleTabsOpen = tabStates?.length > 1; const isOnHomepage = tabUrl === RAINBOW_HOME; - const isEmptyState = !multipleTabsOpen && isOnHomepage; const isLogoUnset = tabStates[tabIndex]?.logoUrl === undefined; + const animatedTabIndex = useSharedValue( + (currentlyOpenTabIds?.value.indexOf(tabId) === -1 + ? currentlyOpenTabIds?.value.length - 1 + : currentlyOpenTabIds?.value.indexOf(tabId)) ?? 0 + ); const screenshotData = useSharedValue(findTabScreenshot(tabId, tabUrl) || undefined); const defaultBackgroundColor = isDarkMode ? '#191A1C' : globalColors.white100; const backgroundColor = useSharedValue(defaultBackgroundColor); + const animatedTabXPosition = useDerivedValue(() => { + return withTiming( + (animatedTabIndex.value % 2) * (TAB_VIEW_COLUMN_WIDTH + 20) - (TAB_VIEW_COLUMN_WIDTH + 20) / 2, + TIMING_CONFIGS.tabPressConfig + ); + }); + + const animatedTabYPosition = useDerivedValue(() => { + return withTiming(Math.floor(animatedTabIndex.value / 2) * TAB_VIEW_ROW_HEIGHT, TIMING_CONFIGS.tabPressConfig); + }); + + const multipleTabsOpen = useDerivedValue(() => { + // The purpose of the following checks is to prevent jarring visual shifts when the tab view transitions + // from having a single tab to multiple tabs. When a second tab is created, it takes a moment for + // tabStates to catch up to currentlyOpenTabIds, and this check prevents the single tab from shifting + // due to currentlyOpenTabIds updating before the new tab component is rendered via tabStates. + const isFirstTab = currentlyOpenTabIds?.value.indexOf(tabId) === 0; + const shouldTwoTabsExist = currentlyOpenTabIds?.value.length === 2; + + const isTransitioningFromSingleToMultipleTabs = + isFirstTab && + shouldTwoTabsExist && + (tabStates?.length === 1 || (tabStates?.length === 2 && currentlyOpenTabIds?.value[1] !== tabStates?.[1]?.uniqueId)); + + const multipleTabsExist = !!(currentlyOpenTabIds?.value && currentlyOpenTabIds?.value.length > 1); + const isLastOrSecondToLastTabAndExiting = currentlyOpenTabIds?.value?.indexOf(tabId) === -1 && currentlyOpenTabIds.value.length === 1; + const multipleTabsOpen = (multipleTabsExist && !isTransitioningFromSingleToMultipleTabs) || isLastOrSecondToLastTabAndExiting; + + return multipleTabsOpen; + }); + + const animatedMultipleTabsOpen = useDerivedValue(() => { + return withTiming(multipleTabsOpen.value ? 1 : 0, TIMING_CONFIGS.tabPressConfig); + }); + const animatedWebViewBackgroundColorStyle = useAnimatedStyle(() => { const homepageColor = isDarkMode ? globalColors.grey100 : '#FBFCFD'; @@ -242,8 +277,8 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje if (isColor(backgroundColor.value)) { const rgbaColor = convertToRGBA(backgroundColor.value); - if (rgbaColor[3] < 1) { - return { backgroundColor: `rgba(${rgbaColor[0]}, ${rgbaColor[1]}, ${rgbaColor[2]}, 1)` }; + if (rgbaColor[3] < 1 && rgbaColor[3] !== 0) { + return { backgroundColor: `rgba(${rgbaColor[0] * 255}, ${rgbaColor[1] * 255}, ${rgbaColor[2] * 255}, 1)` }; } else { return { backgroundColor: backgroundColor.value }; } @@ -255,7 +290,7 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje const animatedWebViewHeight = useDerivedValue(() => { // For some reason driving the WebView height with a separate derived // value results in slightly less tearing when the height animates - const animatedIsActiveTab = animatedActiveTabIndex?.value === tabIndex; + const animatedIsActiveTab = animatedActiveTabIndex?.value === animatedTabIndex.value; if (!animatedIsActiveTab) return COLLAPSED_WEBVIEW_HEIGHT_UNSCALED; const progress = tabViewProgress?.value || 0; @@ -268,35 +303,36 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje ); }); - const animatedTabIndex = useDerivedValue(() => tabIndex); - const animatedWebViewStyle = useAnimatedStyle(() => { const progress = tabViewProgress?.value || 0; - const animatedIsActiveTab = animatedActiveTabIndex?.value === tabIndex; + const animatedIsActiveTab = animatedActiveTabIndex?.value === animatedTabIndex.value; + const isTabBeingClosed = currentlyOpenTabIds?.value?.indexOf(tabId) === -1; + const scaleDiff = 0.7 - TAB_VIEW_COLUMN_WIDTH / deviceWidth; const scale = interpolate( progress, [0, 100], - [animatedIsActiveTab ? 1 : TAB_VIEW_COLUMN_WIDTH / deviceWidth, multipleTabsOpen ? TAB_VIEW_COLUMN_WIDTH / deviceWidth : 0.7] + [animatedIsActiveTab && !isTabBeingClosed ? 1 : TAB_VIEW_COLUMN_WIDTH / deviceWidth, 0.7 - scaleDiff * animatedMultipleTabsOpen.value] ); - const xPositionStart = animatedIsActiveTab ? 0 : (tabIndex % 2) * (TAB_VIEW_COLUMN_WIDTH + 20) - (TAB_VIEW_COLUMN_WIDTH + 20) / 2; - const xPositionEnd = multipleTabsOpen ? (tabIndex % 2) * (TAB_VIEW_COLUMN_WIDTH + 20) - (TAB_VIEW_COLUMN_WIDTH + 20) / 2 : 0; + const xPositionStart = animatedIsActiveTab ? 0 : animatedTabXPosition.value; + const xPositionEnd = animatedMultipleTabsOpen.value * animatedTabXPosition.value; const xPositionForTab = interpolate(progress, [0, 100], [xPositionStart, xPositionEnd]); const extraYPadding = 20; const yPositionStart = - (animatedIsActiveTab ? 0 : Math.floor(tabIndex / 2) * TAB_VIEW_ROW_HEIGHT + extraYPadding) + + (animatedIsActiveTab ? 0 : animatedTabYPosition.value + extraYPadding) + (animatedIsActiveTab ? (1 - progress / 100) * (scrollViewOffset?.value || 0) : 0); const yPositionEnd = - (multipleTabsOpen ? Math.floor(tabIndex / 2) * TAB_VIEW_ROW_HEIGHT + extraYPadding : 0) + + (animatedTabYPosition.value + extraYPadding) * animatedMultipleTabsOpen.value + (animatedIsActiveTab ? (1 - progress / 100) * (scrollViewOffset?.value || 0) : 0); const yPositionForTab = interpolate(progress, [0, 100], [yPositionStart, yPositionEnd]); // Determine the border radius for the minimized tab that // achieves concentric corners around the close button - const invertedScale = multipleTabsOpen ? INVERTED_MULTI_TAB_SCALE : INVERTED_SINGLE_TAB_SCALE; + const invertedScaleDiff = INVERTED_SINGLE_TAB_SCALE - INVERTED_MULTI_TAB_SCALE; + const invertedScale = INVERTED_SINGLE_TAB_SCALE - invertedScaleDiff * animatedMultipleTabsOpen.value; const spaceToXButton = invertedScale * X_BUTTON_PADDING; const xButtonBorderRadius = (X_BUTTON_SIZE / 2) * invertedScale; const tabViewBorderRadius = xButtonBorderRadius + spaceToXButton; @@ -310,34 +346,39 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje ); const opacity = interpolate(progress, [0, 100], [animatedIsActiveTab ? 1 : 0, 1], 'clamp'); + // eslint-disable-next-line no-nested-ternary return { borderRadius, height: animatedWebViewHeight.value, opacity, + pointerEvents: tabViewVisible?.value ? 'auto' : animatedIsActiveTab ? 'auto' : 'none', transform: [ - { translateY: multipleTabsOpen ? -animatedWebViewHeight.value / 2 : 0 }, + { translateY: animatedMultipleTabsOpen.value * (-animatedWebViewHeight.value / 2) }, { translateX: xPositionForTab + gestureX.value }, { translateY: yPositionForTab + gestureY.value }, { scale: scale * gestureScale.value }, - { translateY: multipleTabsOpen ? animatedWebViewHeight.value / 2 : 0 }, + { translateY: animatedMultipleTabsOpen.value * (animatedWebViewHeight.value / 2) }, ], }; }); const zIndexAnimatedStyle = useAnimatedStyle(() => { const progress = tabViewProgress?.value || 0; - const animatedIsActiveTab = animatedActiveTabIndex?.value === tabIndex; + const animatedIsActiveTab = animatedActiveTabIndex?.value === animatedTabIndex.value; + const wasCloseButtonPressed = gestureScale.value === 1 && gestureX.value < 0; + const scaleDiff = 0.7 - TAB_VIEW_COLUMN_WIDTH / deviceWidth; const scaleWeighting = gestureScale.value * interpolate( progress, [0, 100], - [animatedIsActiveTab ? 1 : TAB_VIEW_COLUMN_WIDTH / deviceWidth, multipleTabsOpen ? TAB_VIEW_COLUMN_WIDTH / deviceWidth : 0.7], + [animatedIsActiveTab ? 1 : TAB_VIEW_COLUMN_WIDTH / deviceWidth, 0.7 - scaleDiff * animatedMultipleTabsOpen.value], 'clamp' ); - const zIndex = scaleWeighting * (animatedIsActiveTab || gestureScale.value > 1 ? 9999 : 1); + + const zIndex = scaleWeighting * (animatedIsActiveTab || gestureScale.value > 1 ? 9999 : 1) + (wasCloseButtonPressed ? 9999 : 0); return { zIndex }; }); @@ -413,8 +454,7 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje if (webViewRef.current !== null && isActiveTab) { activeTabRef.current = webViewRef.current; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActiveTab, isOnHomepage, tabId]); + }, [activeTabRef, isActiveTab, isOnHomepage, tabId]); const saveScreenshotToFileSystem = useCallback( async (tempUri: string, tabId: string, timestamp: number, url: string) => { @@ -545,7 +585,6 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje }, }); } - // eslint-disable-next-line no-empty } catch (e) { console.error('Error parsing message', e); @@ -607,15 +646,13 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje if (ctx.startX === undefined) { gestureScale.value = withTiming(1.1, TIMING_CONFIGS.tabPressConfig); - gestureY.value = withTiming(-0.05 * (multipleTabsOpen ? TAB_VIEW_TAB_HEIGHT : 0), TIMING_CONFIGS.tabPressConfig); + gestureY.value = withTiming(-0.05 * (animatedMultipleTabsOpen.value * TAB_VIEW_TAB_HEIGHT), TIMING_CONFIGS.tabPressConfig); ctx.startX = e.absoluteX; } - setNativeProps(scrollViewRef, { scrollEnabled: false }); - dispatchCommand(scrollViewRef, 'scrollTo', [0, scrollViewOffset?.value, true]); - const xDelta = e.absoluteX - ctx.startX; gestureX.value = xDelta; + setNativeProps(scrollViewRef, { scrollEnabled: false }); }, onEnd: (e, ctx: { startX?: number }) => { const xDelta = e.absoluteX - (ctx.startX || 0); @@ -623,18 +660,38 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje const isBeyondDismissThreshold = xDelta < -(TAB_VIEW_COLUMN_WIDTH / 2 + 20) && e.velocityX <= 0; const isFastLeftwardSwipe = e.velocityX < -500; + const isEmptyState = !multipleTabsOpen.value && isOnHomepage; const shouldDismiss = !!tabViewVisible?.value && !isEmptyState && (isBeyondDismissThreshold || isFastLeftwardSwipe); if (shouldDismiss) { - const xDestination = -Math.min(Math.max(deviceWidth * 1.25, Math.abs(e.velocityX * 0.3)), 1000); + const xDestination = -Math.min(Math.max(deviceWidth, deviceWidth + Math.abs(e.velocityX * 0.2)), 1200); + // Store the tab's index before modifying currentlyOpenTabIds, so we can pass it along to closeTabWorklet() + const storedTabIndex = currentlyOpenTabIds?.value.indexOf(tabId) ?? tabIndex; + // Remove the tab from currentlyOpenTabIds as soon as the swipe-to-close gesture is confirmed + currentlyOpenTabIds?.modify(value => { + const index = value.indexOf(tabId); + if (index !== -1) { + value.splice(index, 1); + } + return value; + }); gestureX.value = withTiming(xDestination, TIMING_CONFIGS.tabPressConfig, () => { - runOnJS(closeTab)(tabId); + // Ensure the tab remains hidden after being swiped off screen (until the tab is destroyed) gestureScale.value = 0; - gestureX.value = 0; - gestureY.value = 0; - ctx.startX = undefined; + // Because the animation is complete we know the tab is off screen and can be safely destroyed + closeTabWorklet(tabId, storedTabIndex); }); + + // In the event the last or second-to-last tab is closed, we animate its Y position to align with the + // vertical center of the single remaining tab as this tab exits and the remaining tab scales up. + const isLastOrSecondToLastTabAndExiting = + currentlyOpenTabIds?.value?.indexOf(tabId) === -1 && currentlyOpenTabIds.value.length === 1; + if (isLastOrSecondToLastTabAndExiting) { + const existingYTranslation = gestureY.value; + const scaleDiff = 0.7 - TAB_VIEW_COLUMN_WIDTH / deviceWidth; + gestureY.value = withTiming(existingYTranslation + scaleDiff * COLLAPSED_WEBVIEW_HEIGHT_UNSCALED, TIMING_CONFIGS.tabPressConfig); + } } else { gestureScale.value = withTiming(1, TIMING_CONFIGS.tabPressConfig); gestureX.value = withTiming(0, TIMING_CONFIGS.tabPressConfig); @@ -647,24 +704,24 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje const pressTabGestureHandler = useAnimatedGestureHandler({ onActive: () => { if (tabViewVisible?.value) { - toggleTabViewWorklet(tabIndex); + toggleTabViewWorklet(animatedTabIndex.value); } }, }); - const pressCloseTabGestureHandler = useAnimatedGestureHandler({ - onActive: () => { - closeTab(tabId); - }, - }); useAnimatedReaction( () => tabViewProgress?.value, (current, previous) => { // Monitor changes in tabViewProgress and trigger tab screenshot capture if necessary const changesDetected = previous && current !== previous; - const isActiveTab = animatedActiveTabIndex?.value === tabIndex; - - if (isActiveTab && changesDetected && !isOnHomepage) { + const isTabBeingClosed = currentlyOpenTabIds?.value?.indexOf(tabId) === -1; + + // Note: Using the JS-side isActiveTab because this should be in sync with the WebView freeze state, + // which is driven by isActiveTab. This should allow screenshots slightly more time to capture. + if (isActiveTab && changesDetected && !isOnHomepage && !isTabBeingClosed) { + // ⚠️ TODO: Need to rewrite the enterTabViewAnimationIsComplete condition, because it assumes the + // tab animation will overshoot and rebound. If the animation config is changed, it's possible the + // screenshot condition won't be met. const enterTabViewAnimationIsComplete = tabViewVisible?.value === true && (previous || 0) > 100 && (current || 0) <= 100; const isPageLoaded = (loadProgress?.value || 0) > 0.2; @@ -685,30 +742,33 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje } ); + useAnimatedReaction( + () => ({ currentlyOpenTabIds: currentlyOpenTabIds?.value }), + current => { + const currentIndex = current.currentlyOpenTabIds?.indexOf(tabId) ?? -1; + // This allows us to give the tab its previous animated index when it's being closed, so that the close + // animation is allowed to complete with the X and Y coordinates it had based on its last real index. + if (currentIndex >= 0) { + animatedTabIndex.value = currentIndex; + } + } + ); + return ( <> {/* Need to fix some shadow performance issues - disabling shadows for now */} {/* */} {/* @ts-expect-error Property 'children' does not exist on type */} - - + + {/* @ts-expect-error Property 'children' does not exist on type */} @@ -731,15 +791,8 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje mediaPlaybackRequiresUserAction onLoadStart={handleOnLoadStart} onLoad={handleOnLoad} - // 👇 This prevents an occasional white page flash when loading - renderLoading={() => ( - - )} + // 👇 This prevents the WebView from hiding its content on load/reload + renderLoading={() => <>} onLoadEnd={handleOnLoadEnd} onError={handleOnError} onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest} @@ -748,20 +801,22 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje onNavigationStateChange={handleNavigationStateChange} ref={webViewRef} source={{ uri: tabUrl || RAINBOW_HOME }} - style={[styles.webViewStyle, styles.transparentBackground]} + style={styles.webViewStyle} /> )} - - + + { - closeTab(tabId); - }} + animatedMultipleTabsOpen={animatedMultipleTabsOpen} + gestureX={gestureX} + gestureY={gestureY} + isOnHomepage={isOnHomepage} + multipleTabsOpen={multipleTabsOpen} + tabId={tabId} tabIndex={tabIndex} - ref={tapCloseRef} /> @@ -791,9 +846,6 @@ const styles = StyleSheet.create({ width: deviceUtils.dimensions.width, zIndex: 20000, }, - transparentBackground: { - backgroundColor: 'transparent', - }, webViewContainer: { alignSelf: 'center', height: WEBVIEW_HEIGHT, @@ -801,9 +853,9 @@ const styles = StyleSheet.create({ position: 'absolute', top: safeAreaInsetValues.top, width: deviceUtils.dimensions.width, - zIndex: 999999999, }, webViewStyle: { + backgroundColor: 'transparent', borderCurve: 'continuous', height: WEBVIEW_HEIGHT, maxHeight: WEBVIEW_HEIGHT, diff --git a/src/components/DappBrowser/CloseTabButton.tsx b/src/components/DappBrowser/CloseTabButton.tsx index 420ff96fc2d..d0998fdca14 100644 --- a/src/components/DappBrowser/CloseTabButton.tsx +++ b/src/components/DappBrowser/CloseTabButton.tsx @@ -1,14 +1,14 @@ -import React from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; -import Animated, { interpolate, useAnimatedGestureHandler, useAnimatedStyle, withTiming } from 'react-native-reanimated'; -import { Box, Cover, TextIcon, useColorMode } from '@/design-system'; +import React, { useCallback } from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { SharedValue, interpolate, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import { Box, TextIcon, useColorMode } from '@/design-system'; import { IS_IOS } from '@/env'; import { deviceUtils } from '@/utils'; import { AnimatedBlurView } from '@/__swaps__/screens/Swap/components/AnimatedBlurView'; -import { RAINBOW_HOME, useBrowserContext } from './BrowserContext'; -import { TAB_VIEW_COLUMN_WIDTH } from './Dimensions'; -import { TIMING_CONFIGS } from '../animations/animationConfigs'; import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; +import { TIMING_CONFIGS } from '../animations/animationConfigs'; +import { useBrowserContext } from './BrowserContext'; +import { COLLAPSED_WEBVIEW_HEIGHT_UNSCALED, TAB_VIEW_COLUMN_WIDTH } from './Dimensions'; // ⚠️ TODO: Fix close button press detection — currently being blocked // by the gesture handlers within the BrowserTab component. @@ -25,66 +25,170 @@ const SCALE_ADJUSTED_X_BUTTON_PADDING = X_BUTTON_PADDING * INVERTED_WEBVIEW_SCAL const SCALE_ADJUSTED_X_BUTTON_SIZE_SINGLE_TAB = X_BUTTON_SIZE * SINGLE_TAB_INVERTED_WEBVIEW_SCALE; const SCALE_ADJUSTED_X_BUTTON_PADDING_SINGLE_TAB = X_BUTTON_PADDING * SINGLE_TAB_INVERTED_WEBVIEW_SCALE; -const CloseTabButton = ( - { onPress, tabIndex }: { onPress: () => void; tabIndex: number }, - forwardedRef: React.LegacyRef | undefined -) => { - const { animatedActiveTabIndex, tabStates, tabViewProgress, tabViewVisible } = useBrowserContext(); +export const CloseTabButton = ({ + animatedMultipleTabsOpen, + gestureX, + gestureY, + isOnHomepage, + multipleTabsOpen, + tabId, + tabIndex, +}: { + animatedMultipleTabsOpen: SharedValue; + gestureX: SharedValue; + gestureY: SharedValue; + isOnHomepage: boolean; + multipleTabsOpen: SharedValue; + tabId: string; + tabIndex: number; +}) => { + const { animatedActiveTabIndex, closeTabWorklet, currentlyOpenTabIds, tabViewProgress, tabViewVisible } = useBrowserContext(); const { isDarkMode } = useColorMode(); - const multipleTabsOpen = tabStates.length > 1; - const tabUrl = tabStates[tabIndex]?.url; - const isOnHomepage = tabUrl === RAINBOW_HOME; - const isEmptyState = !multipleTabsOpen && isOnHomepage; - const buttonSize = multipleTabsOpen ? SCALE_ADJUSTED_X_BUTTON_SIZE : SCALE_ADJUSTED_X_BUTTON_SIZE_SINGLE_TAB; - const buttonPadding = multipleTabsOpen ? SCALE_ADJUSTED_X_BUTTON_PADDING : SCALE_ADJUSTED_X_BUTTON_PADDING_SINGLE_TAB; - const closeButtonStyle = useAnimatedStyle(() => { const progress = tabViewProgress?.value || 0; - const isActiveTab = animatedActiveTabIndex?.value === tabIndex; + const rawAnimatedTabIndex = currentlyOpenTabIds?.value.indexOf(tabId); + const animatedTabIndex = rawAnimatedTabIndex === -1 ? tabIndex : rawAnimatedTabIndex ?? tabIndex; + const animatedIsActiveTab = animatedActiveTabIndex?.value === animatedTabIndex; // Switch to using progress-based interpolation when the tab view is // entered. This is mainly to avoid showing the close button in the // active tab until the tab view animation is near complete. - const interpolatedOpacity = interpolate(progress, [0, 80, 100], [isActiveTab ? 0 : 1, isActiveTab ? 0 : 1, 1]); - const opacity = - !isEmptyState && (tabViewVisible?.value || !isActiveTab) ? interpolatedOpacity : withTiming(0, TIMING_CONFIGS.fastFadeConfig); + const interpolatedOpacity = interpolate(progress, [0, 80, 100], [animatedIsActiveTab ? 0 : 1, animatedIsActiveTab ? 0 : 1, 1]); + const opacity = tabViewVisible?.value || !animatedIsActiveTab ? interpolatedOpacity : withTiming(0, TIMING_CONFIGS.fastFadeConfig); + return { opacity }; }); - const pointerEventsStyle = useAnimatedStyle(() => { + const containerStyle = useAnimatedStyle(() => { + const buttonPadding = multipleTabsOpen.value ? SCALE_ADJUSTED_X_BUTTON_PADDING : SCALE_ADJUSTED_X_BUTTON_PADDING_SINGLE_TAB; + const buttonSize = multipleTabsOpen.value ? SCALE_ADJUSTED_X_BUTTON_SIZE : SCALE_ADJUSTED_X_BUTTON_SIZE_SINGLE_TAB; + + const isEmptyState = isOnHomepage && !multipleTabsOpen.value; + const opacity = isEmptyState ? withTiming(0, TIMING_CONFIGS.tabPressConfig) : withTiming(1, TIMING_CONFIGS.tabPressConfig); const pointerEvents = tabViewVisible?.value && !isEmptyState ? 'auto' : 'none'; - return { pointerEvents }; + + return { + height: buttonSize, + opacity, + pointerEvents, + right: buttonPadding, + top: buttonPadding, + width: buttonSize, + }; + }); + + const multipleTabsStyle = useAnimatedStyle(() => { + return { + opacity: interpolate(animatedMultipleTabsOpen.value, [0, 0.9, 1], [0, 0, 1], 'clamp'), + pointerEvents: multipleTabsOpen.value ? 'auto' : 'none', + }; + }); + + const singleTabStyle = useAnimatedStyle(() => { + return { + opacity: interpolate(animatedMultipleTabsOpen.value, [0, 0.1, 1], [1, 0, 0], 'clamp'), + pointerEvents: multipleTabsOpen.value ? 'none' : 'auto', + }; }); + const closeTab = useCallback(() => { + 'worklet'; + const storedTabIndex = currentlyOpenTabIds?.value.indexOf(tabId) ?? tabIndex; + + const isOnlyOneTabOpen = (currentlyOpenTabIds?.value.length || 0) === 1; + const isTabInLeftColumn = storedTabIndex % 2 === 0 && !isOnlyOneTabOpen; + const xDestination = isTabInLeftColumn ? -deviceUtils.dimensions.width / 1.5 : -deviceUtils.dimensions.width; + + currentlyOpenTabIds?.modify(value => { + const index = value.indexOf(tabId); + if (index !== -1) { + value.splice(index, 1); + } + return value; + }); + gestureX.value = withTiming(xDestination, TIMING_CONFIGS.tabPressConfig, () => { + // Because the animation is complete we know the tab is off screen and can be safely destroyed + closeTabWorklet(tabId, storedTabIndex); + }); + + // In the event the last or second-to-last tab is closed, we animate its Y position to align with the + // vertical center of the single remaining tab as this tab exits and the remaining tab scales up. + const isLastOrSecondToLastTabAndExiting = currentlyOpenTabIds?.value?.indexOf(tabId) === -1 && currentlyOpenTabIds.value.length === 1; + if (isLastOrSecondToLastTabAndExiting) { + const existingYTranslation = gestureY.value; + const scaleDiff = 0.7 - TAB_VIEW_COLUMN_WIDTH / deviceUtils.dimensions.width; + gestureY.value = withTiming(existingYTranslation + scaleDiff * COLLAPSED_WEBVIEW_HEIGHT_UNSCALED, TIMING_CONFIGS.tabPressConfig); + } + }, [closeTabWorklet, currentlyOpenTabIds, gestureX, gestureY, tabId, tabIndex]); + return ( - <> - - - - {IS_IOS ? ( + + + + {IS_IOS ? ( + - - - - ) : ( + background="fillSecondary" + borderRadius={SCALE_ADJUSTED_X_BUTTON_SIZE_SINGLE_TAB / 2} + height="full" + position="absolute" + width="full" + /> + + + ) : ( + + + + )} + + + {IS_IOS ? ( + - - - )} - - - - + background="fillSecondary" + borderRadius={SCALE_ADJUSTED_X_BUTTON_SIZE / 2} + height="full" + position="absolute" + width="full" + /> + + + ) : ( + + + + )} + + + ); }; @@ -107,12 +211,8 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - closeButtonWrapperStyle: { - position: 'absolute', - }, containerStyle: { + position: 'absolute', zIndex: 99999999999, }, }); - -export default React.forwardRef(CloseTabButton); diff --git a/src/components/DappBrowser/TabViewToolbar.tsx b/src/components/DappBrowser/TabViewToolbar.tsx index 7839cc704be..3e5dd5d3356 100644 --- a/src/components/DappBrowser/TabViewToolbar.tsx +++ b/src/components/DappBrowser/TabViewToolbar.tsx @@ -60,9 +60,9 @@ export const TabViewToolbar = () => { }; const NewTabButton = () => { - const { newTab } = useBrowserContext(); + const { newTabWorklet } = useBrowserContext(); - return ; + return ; }; const DoneButton = () => { @@ -117,7 +117,7 @@ const BaseButton = ({ @@ -133,9 +133,11 @@ const BaseButton = ({ blurType={isDarkMode ? 'dark' : 'light'} style={[ { - zIndex: -1, - elevation: -1, + borderCurve: 'continuous', borderRadius: 22, + elevation: -1, + overflow: 'hidden', + zIndex: -1, }, position.coverAsObject, ]} @@ -146,8 +148,10 @@ const BaseButton = ({ { backgroundColor: buttonColor, borderColor: separatorSecondary, + borderCurve: 'continuous', borderRadius: 22, borderWidth: IS_IOS && isDarkMode ? THICK_BORDER_WIDTH : 0, + overflow: 'hidden', zIndex: -1, }, position.coverAsObject, diff --git a/src/components/DappBrowser/utils.ts b/src/components/DappBrowser/utils.ts index 84539501533..a55cf3ef8c3 100644 --- a/src/components/DappBrowser/utils.ts +++ b/src/components/DappBrowser/utils.ts @@ -27,6 +27,13 @@ export const generateUniqueId = (): string => { return `${timestamp}${randomString}`; }; +export function generateUniqueIdWorklet(): string { + 'worklet'; + const timestamp = Date.now().toString(36); + const randomString = Math.random().toString(36).slice(2, 7); + return `${timestamp}${randomString}`; +} + export const getNameFromFormattedUrl = (formattedUrl: string): string => { const parts = formattedUrl.split('.'); let name;