Skip to content
Draft
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
23 changes: 17 additions & 6 deletions src/app/[lng]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
THEME_COLOR_SCHEME_STORAGE_KEY,
THEME_MODE_STORAGE_KEY,
} from '@/providers/ThemeProvider/constants';
import { ExtensionDetectionRoot } from '@/providers/ExtensionDetectionProvider/ExtensionDetectionRoot';
import { getPocketUniverseHtmlDataCsnSnapshotInlineScript } from '@/providers/ExtensionDetectionProvider/utils';

const PUBLIC_URL = envConfig.NEXT_PUBLIC_SITE_URL as string;
export const metadata: Metadata = {
Expand Down Expand Up @@ -125,6 +127,13 @@ export default async function RootLayout({
style={{ scrollBehavior: 'smooth' }}
>
<head>
<script
id="pocket-universe-html-data-csn-snapshot"
data-cfasync="false"
dangerouslySetInnerHTML={{
__html: getPocketUniverseHtmlDataCsnSnapshotInlineScript(),
}}
/>
<script
id="theme-bootstrap"
data-cfasync="false"
Expand Down Expand Up @@ -234,12 +243,14 @@ export default async function RootLayout({
<SettingsStoreProvider>
<NuqsAdapter>
<PortfolioProvider>
<Suspense>
<ReferrerCapture />
</Suspense>
<NavbarWrapper />
<IntercomProvider />
<main>{children}</main>
<ExtensionDetectionRoot>
<Suspense>
<ReferrerCapture />
</Suspense>
<NavbarWrapper />
<IntercomProvider />
<main>{children}</main>
</ExtensionDetectionRoot>
</PortfolioProvider>
</NuqsAdapter>
</SettingsStoreProvider>
Expand Down
41 changes: 41 additions & 0 deletions src/app/ui/app/AlertBannerWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { BaseAlert } from '@/components/Alerts/BaseAlert/BaseAlert';
import { BaseAlertVariant } from '@/components/Alerts/BaseAlert/BaseAlert.styles';
import Box from '@mui/material/Box';
import Fade from '@mui/material/Fade';
import { type FC } from 'react';

import { useExtension } from '@/providers/ExtensionDetectionProvider/ExtensionDetectionProvider';
import { POCKET_UNIVERSE_EXTENSION } from '@/providers/ExtensionDetectionProvider/extensionDetectionInitialDefinitions';
import { useTranslation } from 'react-i18next';
import { useAccount } from '@lifi/wallet-management';

export const AlertBannerWrapper: FC = ({}) => {
const { t } = useTranslation();
const { detected } = useExtension(POCKET_UNIVERSE_EXTENSION);
const { account } = useAccount();

return (
<Fade in={detected && !!account?.isConnected} timeout={400}>
<Box
sx={{
marginTop: 2,
maxWidth: {
xs: '100%',
sm: 420,
},
}}
>
<BaseAlert
title={t('alerts.extension', {
extensionName: 'Pocket Universe',
fee: 0.8,
})}
variant={BaseAlertVariant.Warning}
sx={{
'.MuiTypography-root': { fontWeight: 500 },
}}
/>
</Box>
</Fade>
);
};
2 changes: 2 additions & 0 deletions src/app/ui/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { WelcomeScreen } from '@/components/WelcomeScreen/WelcomeScreen';
import { TrackingAction, TrackingCategory } from '@/const/trackingKeys';
import { useWelcomeScreen } from '@/hooks/useWelcomeScreen';
import dynamic from 'next/dynamic';
import { AlertBannerWrapper } from './AlertBannerWrapper';

const AnnouncementBannerWrapper = dynamic(() =>
import('./AnnouncementBannerWrapper').then(
Expand Down Expand Up @@ -86,6 +87,7 @@ const App = ({ children }: { children: React.ReactNode }) => {
<AnnouncementBannerWrapper ref={announcementBannersRef} />
)}
{children}
<AlertBannerWrapper />
</WelcomeOverlayLayout>
);
};
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/resources.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,9 @@ interface Resources {
deleteNotification: 'Delete notification';
};
};
alerts: {
extension: "We detect that you're using {{extensionName}}. This extension can overwrite transactions on top of Jumper and charge a {{fee, number}}% fee on top.";
};
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/i18n/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -937,5 +937,8 @@
"openPanel": "Notifications",
"deleteNotification": "Delete notification"
}
},
"alerts": {
"extension": "We detect that you're using {{extensionName}}. This extension can overwrite transactions on top of Jumper and charge a {{fee, number}}% fee on top."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client';

import './extensionDetectionRegister';

import React, { useCallback, useEffect } from 'react';
import { useStore } from 'zustand';
import { extensionDetectionStore } from './extensionDetectionSingletonStore';
import {
DEFAULT_EXTENSION_STATUS,
type ExtensionDetectionStore,
ExtensionDetectionStoreContext,
useExtensionDetectionStore,
} from './store';
import type { ExtensionStatus } from './utils';

export type {
Eip6963AnnounceProviderMatch,
ExtensionDefinition,
ExtensionDetector,
ExtensionStatus,
} from './utils';

export { extensionDetectionStore } from './extensionDetectionSingletonStore';
export {
chromeExtensionInjectedDetector,
domElementDetector,
eip6963AnnounceProviderDetector,
globalVariableDetector,
messageHandshakeDetector,
mutationObserverDetector,
pocketUniverseDatasetCsnDetector,
pocketUniverseHtmlDataCsnSnapshotDetector,
resourceFetchDetector,
stylesheetDetector,
} from './utils';

export interface ExtensionDetectionProviderProps {
children: React.ReactNode;
pollingInterval?: number;
}

export function ExtensionDetectionProvider({
children,
pollingInterval,
}: ExtensionDetectionProviderProps) {
const store = extensionDetectionStore;

useEffect(() => {
if (!pollingInterval) {
return;
}
const id = setInterval(() => {
const { runCheck, registry } = store.getState();
Array.from(registry.keys()).forEach(runCheck);
}, pollingInterval);
return () => clearInterval(id);
}, [store, pollingInterval]);

return (
<ExtensionDetectionStoreContext.Provider value={store}>
{children}
</ExtensionDetectionStoreContext.Provider>
);
}

export function useExtension(name: string): ExtensionStatus {
const store = useExtensionDetectionStore();
const key = name.toLowerCase();
const selectStatus = useCallback(
(state: ExtensionDetectionStore) =>
state.statusMap[key] ?? DEFAULT_EXTENSION_STATUS,
[key],
);
return useStore(store, selectStatus);
}

export function useExtensionDetection() {
const store = useExtensionDetectionStore();
const selectRunCheck = useCallback(
(s: ExtensionDetectionStore) => s.runCheck,
[],
);
const selectRegister = useCallback(
(s: ExtensionDetectionStore) => s.register,
[],
);
const recheck = useStore(store, selectRunCheck);
const register = useStore(store, selectRegister);
return { recheck, register };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use client';

import type { ReactNode } from 'react';

import { ExtensionDetectionProvider } from './ExtensionDetectionProvider';

export function ExtensionDetectionRoot({ children }: { children: ReactNode }) {
return <ExtensionDetectionProvider>{children}</ExtensionDetectionProvider>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { ExtensionDefinition } from './utils';
import {
chromeExtensionInjectedDetector,
eip6963AnnounceProviderDetector,
POCKET_UNIVERSE_HTML_DATA_CSN_SNAPSHOT_OBSERVE_MS,
pocketUniverseDatasetCsnDetector,
pocketUniverseHtmlDataCsnSnapshotDetector,
} from './utils';

export const POCKET_UNIVERSE_EXTENSION = 'pocket';

const POCKET_UNIVERSE_EXTENSION_ID = 'gacgndbocaddlemdiaadajmlggabdeod';

export const pocketUniverseExtensionDefinition: ExtensionDefinition = {
name: POCKET_UNIVERSE_EXTENSION,
detectors: [
pocketUniverseHtmlDataCsnSnapshotDetector(),
chromeExtensionInjectedDetector(POCKET_UNIVERSE_EXTENSION_ID),
eip6963AnnounceProviderDetector({ nameIncludes: 'Pocket Universe' }, 8000),
pocketUniverseDatasetCsnDetector(
POCKET_UNIVERSE_HTML_DATA_CSN_SNAPSHOT_OBSERVE_MS,
),
],
};

export const extensionDetectionInitialDefinitions: ExtensionDefinition[] = [
pocketUniverseExtensionDefinition,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use client';

import { extensionDetectionInitialDefinitions } from './extensionDetectionInitialDefinitions';
import { extensionDetectionStore } from './extensionDetectionSingletonStore';

extensionDetectionStore
.getState()
.initRegistry(extensionDetectionInitialDefinitions);

for (const def of extensionDetectionInitialDefinitions) {
void extensionDetectionStore.getState().runCheck(def.name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use client';

import { createExtensionDetectionStore } from './store';

export const extensionDetectionStore = createExtensionDetectionStore();
100 changes: 100 additions & 0 deletions src/providers/ExtensionDetectionProvider/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
'use client';

import { createContext, useContext } from 'react';
import { createStore, type StoreApi } from 'zustand';
import {
type ExtensionDefinition,
type ExtensionStatus,
runDetectors,
} from './utils';

export interface ExtensionDetectionStore {
statusMap: Record<string, ExtensionStatus>;
registry: Map<string, ExtensionDefinition>;
initRegistry: (propDetectors?: ExtensionDefinition[]) => void;
updateStatus: (name: string, patch: Partial<ExtensionStatus>) => void;
runCheck: (name: string) => Promise<void>;
register: (definition: ExtensionDefinition) => void;
}

/** Stable fallback for selectors; avoid new object each snapshot (Zustand `useSyncExternalStore`). */
export const DEFAULT_EXTENSION_STATUS: ExtensionStatus = {
detected: false,
loading: true,
error: null,
};

export function createExtensionDetectionStore(): StoreApi<ExtensionDetectionStore> {
return createStore<ExtensionDetectionStore>((set, get) => ({
statusMap: {},
registry: new Map<string, ExtensionDefinition>(),

initRegistry: (propDetectors) => {
const merged = new Map<string, ExtensionDefinition>();
propDetectors?.forEach((d) => merged.set(d.name.toLowerCase(), d));
set({ registry: merged });
},

updateStatus: (name, patch) => {
set((state) => ({
statusMap: {
...state.statusMap,
[name]: {
...DEFAULT_EXTENSION_STATUS,
...state.statusMap[name],
...patch,
},
},
}));
},

runCheck: async (name) => {
const key = name.toLowerCase();
const { registry, updateStatus } = get();
const definition = registry.get(key);

if (!definition) {
updateStatus(key, {
loading: false,
error: new Error(`No detectors registered for extension: "${name}"`),
});
return;
}

updateStatus(key, { loading: true, error: null });

try {
const detected = await runDetectors(definition);
updateStatus(key, { detected, loading: false });
} catch (err) {
updateStatus(key, {
loading: false,
error: err instanceof Error ? err : new Error(String(err)),
});
}
},

register: (definition) => {
const key = definition.name.toLowerCase();
set((state) => {
const registry = new Map(state.registry);
registry.set(key, definition);
return { registry };
});
get().runCheck(definition.name);
},
}));
}

export const ExtensionDetectionStoreContext =
createContext<StoreApi<ExtensionDetectionStore> | null>(null);

export function useExtensionDetectionStore(): StoreApi<ExtensionDetectionStore> {
const store = useContext(ExtensionDetectionStoreContext);
if (!store) {
throw new Error(
'useExtensionDetectionStore must be used within an <ExtensionDetectionProvider>',
);
}
return store;
}
Loading
Loading