diff --git a/README.md b/README.md index bc7429a1..2d594ab8 100755 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Inside this project, you'll see the following folders and files: │ ├── content/ # Source code for the content scripts │ │ └── keyAutoAdd/ # content scripts for automatic key addition to wallets │ ├── popup/ # Source code for the popup UI +│ ├── pages/ # Source code for additional extension pages │ ├── shared/ # Shared utilities │ └── manifest.json # Extension's manifest - processed by Webpack depending on the target build ├── jest.config.ts diff --git a/esbuild/config.ts b/esbuild/config.ts index f6b72f3b..26ff7f7e 100644 --- a/esbuild/config.ts +++ b/esbuild/config.ts @@ -40,6 +40,10 @@ export const options: BuildOptions = { in: path.join(SRC_DIR, 'popup', 'index.tsx'), out: path.join('popup', 'popup'), }, + { + in: path.join(SRC_DIR, 'pages', 'progress-connect', 'index.tsx'), + out: path.join('pages', 'progress-connect', 'progress-connect'), + }, ], bundle: true, legalComments: 'none', diff --git a/esbuild/dev.ts b/esbuild/dev.ts index 6fd6adde..02544d17 100644 --- a/esbuild/dev.ts +++ b/esbuild/dev.ts @@ -65,6 +65,20 @@ function liveReloadPlugin({ target }: { target: Target }): ESBuildPlugin { } );`; + const reloadScriptPages = ` + new EventSource("http://localhost:${port}/esbuild").addEventListener( + "change", + (ev) => { + const data = JSON.parse(ev.data); + if ( + data.added.some(s => s.includes("/pages/")) || + data.updated.some(s => s.includes("/pages/")) + ) { + globalThis.location.reload(); + } + } + );`; + const reloadScriptContent = ` new EventSource("http://localhost:${port}/esbuild").addEventListener( "change", @@ -95,6 +109,13 @@ function liveReloadPlugin({ target }: { target: Target }): ESBuildPlugin { loader: 'tsx' as const, }; }); + build.onLoad({ filter: /src\/pages\/.+\/index.tsx$/ }, async (args) => { + const contents = await readFile(args.path, 'utf8'); + return { + contents: contents + '\n\n\n' + reloadScriptPages, + loader: 'tsx' as const, + }; + }); build.onLoad({ filter: /src\/content\// }, async (args) => { const contents = await readFile(args.path, 'utf8'); return { diff --git a/esbuild/plugins.ts b/esbuild/plugins.ts index d65d2fc1..34b96574 100644 --- a/esbuild/plugins.ts +++ b/esbuild/plugins.ts @@ -51,6 +51,10 @@ export const getPlugins = ({ from: path.join(SRC_DIR, 'popup', 'index.html'), to: path.join(outDir, 'popup', 'index.html'), }, + { + from: path.join(SRC_DIR, 'pages', 'progress-connect', 'index.html'), + to: path.join(outDir, 'pages', 'progress-connect', 'index.html'), + }, { from: path.join(SRC_DIR, '_locales/**/*'), to: path.join(outDir, '_locales'), diff --git a/src/background/services/keyAutoAdd.ts b/src/background/services/keyAutoAdd.ts index a2b383e8..63f7a948 100644 --- a/src/background/services/keyAutoAdd.ts +++ b/src/background/services/keyAutoAdd.ts @@ -89,18 +89,21 @@ export class KeyAutoAddService { }; this.browser.tabs.onRemoved.addListener(onTabCloseListener); + const ports = new Set(); const onConnectListener: OnConnectCallback = (port) => { if (port.name !== CONNECTION_NAME) return; if (port.error) { reject(new Error(port.error.message)); return; } + ports.add(port); port.postMessage({ action: 'BEGIN', payload }); port.onMessage.addListener(onMessageListener); port.onDisconnect.addListener(() => { + ports.delete(port); // wait for connect again so we can send message again if not connected, // and not errored already (e.g. page refreshed) }); @@ -108,6 +111,7 @@ export class KeyAutoAddService { const onMessageListener: OnPortMessageListener = ( message: KeyAutoAddToBackgroundMessage, + port, ) => { if (message.action === 'SUCCESS') { this.browser.runtime.onConnect.removeListener(onConnectListener); @@ -124,8 +128,10 @@ export class KeyAutoAddService { ]), ); } else if (message.action === 'PROGRESS') { - // can save progress to show in popup - // console.table(message.payload.steps); + // can also save progress to show in popup + for (const p of ports) { + if (p !== port) p.postMessage(message); + } } else { reject(new Error(`Unexpected message: ${JSON.stringify(message)}`)); } diff --git a/src/content/keyAutoAdd/lib/keyAutoAdd.ts b/src/content/keyAutoAdd/lib/keyAutoAdd.ts index 6eddbd53..24d84401 100644 --- a/src/content/keyAutoAdd/lib/keyAutoAdd.ts +++ b/src/content/keyAutoAdd/lib/keyAutoAdd.ts @@ -1,8 +1,11 @@ +// cSpell:ignore allowtransparency import browser, { type Runtime } from 'webextension-polyfill'; import { CONNECTION_NAME } from '@/background/services/keyAutoAdd'; import { errorWithKeyToJSON, isErrorWithKey, + sleep, + withResolvers, type ErrorWithKeyLike, } from '@/shared/helpers'; import type { @@ -24,6 +27,7 @@ const SYMBOL_SKIP = Symbol.for('skip'); export class KeyAutoAdd { private port: Runtime.Port; + private ui: HTMLIFrameElement; private stepsInput: Map; private steps: StepWithStatus[]; @@ -45,6 +49,78 @@ export class KeyAutoAdd { ); } + private setNotificationSize(size: 'notification' | 'fullscreen' | 'hidden') { + let styles: Partial; + const defaultStyles: Partial = { + outline: 'none', + border: 'none', + zIndex: '9999', + position: 'fixed', + top: '0', + left: '0', + }; + + if (size === 'notification') { + styles = { + width: '22rem', + height: '8rem', + position: 'fixed', + top: '1rem', + right: '1rem', + left: 'initial', + boxShadow: 'rgba(0, 0, 0, 0.1) 0px 0px 6px 3px', + borderRadius: '0.5rem', + }; + } else if (size === 'fullscreen') { + styles = { + width: '100vw', + height: '100vh', + }; + } else { + styles = { + width: '0', + height: '0', + position: 'absolute', + }; + } + + this.ui.style.cssText = ''; + Object.assign(this.ui.style, defaultStyles); + Object.assign(this.ui.style, styles); + + const iframeUrl = new URL( + browser.runtime.getURL('pages/progress-connect/index.html'), + ); + const params = new URLSearchParams({ mode: size }); + iframeUrl.hash = '?' + params.toString(); + if (this.ui.src !== iframeUrl.href && size !== 'hidden') { + this.ui.src = iframeUrl.href; + } + } + + private addNotification() { + const { resolve, reject, promise } = withResolvers(); + if (this.ui) { + resolve(); + return promise; + } + const pageUrl = browser.runtime.getURL('pages/progress-connect/index.html'); + const iframe = document.createElement('iframe'); + iframe.setAttribute('allowtransparency', 'true'); + iframe.src = pageUrl; + document.body.appendChild(iframe); + iframe.addEventListener('load', () => { + resolve(); + sleep(500).then(() => + this.postMessage('PROGRESS', { steps: this.steps }), + ); + }); + iframe.addEventListener('error', reject, { once: true }); + this.ui = iframe; + this.setNotificationSize('hidden'); + return promise; + } + private async run({ walletAddressUrl, publicKey, @@ -64,13 +140,24 @@ export class KeyAutoAdd { details: typeof details === 'string' ? new Error(details) : details, }; }, + setNotificationSize: (size: 'notification' | 'fullscreen') => { + this.setNotificationSize(size); + }, }; + + await this.addNotification(); + this.postMessage('PROGRESS', { steps: this.steps }); + let prevStepId = ''; let prevStepResult: unknown = undefined; for (let stepIdx = 0; stepIdx < this.steps.length; stepIdx++) { const step = this.steps[stepIdx]; - this.setStatus(stepIdx, 'active', {}); - this.postMessage('PROGRESS', { steps: this.steps }); + const stepInfo = this.stepsInput.get(step.name)!; + this.setStatus(stepIdx, 'active', { + expiresAt: stepInfo.maxDuration + ? new Date(Date.now() + stepInfo.maxDuration).valueOf() + : undefined, + }); try { prevStepResult = await this.stepsInput .get(step.name)! diff --git a/src/content/keyAutoAdd/lib/types.ts b/src/content/keyAutoAdd/lib/types.ts index 11c73fe4..abb28a1e 100644 --- a/src/content/keyAutoAdd/lib/types.ts +++ b/src/content/keyAutoAdd/lib/types.ts @@ -3,6 +3,7 @@ import type { ErrorResponse } from '@/shared/messages'; export interface StepRunParams extends BeginPayload { skip: (message: string | Error | ErrorWithKeyLike) => never; + setNotificationSize: (size: 'notification' | 'fullscreen') => void; } export type StepRun = ( @@ -15,6 +16,7 @@ export type StepRun = ( export interface Step { name: string; run: StepRun; + maxDuration?: number; } export type Details = Omit; @@ -24,7 +26,11 @@ interface StepWithStatusBase { status: string; } interface StepWithStatusNormal extends StepWithStatusBase { - status: 'pending' | 'active' | 'success'; + status: 'pending' | 'success'; +} +interface StepWithStatusActive extends StepWithStatusBase { + status: 'active'; + expiresAt?: number; } interface StepWithStatusSkipped extends StepWithStatusBase { status: 'skipped'; @@ -37,6 +43,7 @@ interface StepWithStatusError extends StepWithStatusBase { export type StepWithStatus = | StepWithStatusNormal + | StepWithStatusActive | StepWithStatusSkipped | StepWithStatusError; diff --git a/src/content/keyAutoAdd/testWallet.ts b/src/content/keyAutoAdd/testWallet.ts index ad2dabfc..afcf6ace 100644 --- a/src/content/keyAutoAdd/testWallet.ts +++ b/src/content/keyAutoAdd/testWallet.ts @@ -26,13 +26,19 @@ type AccountDetails = { }; }; -const waitForLogin: Step = async ({ skip, keyAddUrl }) => { - let alreadyLoggedIn = false; +const waitForLogin: Step = async ({ + skip, + setNotificationSize, + keyAddUrl, +}) => { + let alreadyLoggedIn = window.location.href.startsWith(keyAddUrl); + if (!alreadyLoggedIn) setNotificationSize('notification'); try { alreadyLoggedIn = await waitForURL( (url) => (url.origin + url.pathname).startsWith(keyAddUrl), { timeout: LOGIN_WAIT_TIMEOUT }, ); + setNotificationSize('fullscreen'); } catch (error) { if (isTimedOut(error)) { throw new ErrorWithKey('connectWalletKeyService_error_timeoutLogin'); @@ -45,7 +51,10 @@ const waitForLogin: Step = async ({ skip, keyAddUrl }) => { } }; -const getAccountDetails: Step = async (_) => { +const getAccountDetails: Step = async ({ + setNotificationSize, +}) => { + setNotificationSize('fullscreen'); await sleep(1000); const NEXT_DATA = document.querySelector('script#__NEXT_DATA__')?.textContent; @@ -155,7 +164,11 @@ async function revokeKey(accountId: string, walletId: string, keyId: string) { // region: Main new KeyAutoAdd([ - { name: 'Waiting for login', run: waitForLogin }, + { + name: 'Waiting for you to login', + run: waitForLogin, + maxDuration: LOGIN_WAIT_TIMEOUT, + }, { name: 'Getting account details', run: getAccountDetails }, { name: 'Revoking existing key', run: revokeExistingKey }, { name: 'Finding wallet', run: findWallet }, diff --git a/src/manifest.json b/src/manifest.json index 64872445..a4d06797 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -34,7 +34,7 @@ }, "web_accessible_resources": [ { - "resources": ["polyfill/*"], + "resources": ["polyfill/*", "pages/progress-connect/*"], "matches": [""] } ], diff --git a/src/pages/progress-connect/App.tsx b/src/pages/progress-connect/App.tsx new file mode 100644 index 00000000..5e583116 --- /dev/null +++ b/src/pages/progress-connect/App.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useUIMode } from '@/pages/progress-connect/context'; +import { AppNotification } from '@/pages/progress-connect/components/AppNotification'; +import { AppFullscreen } from '@/pages/progress-connect/components/AppFullScreen'; + +export default function App() { + const mode = useUIMode(); + + React.useEffect(() => { + const container = document.getElementById('container')!; + container.style.height = mode === 'fullscreen' ? '100vh' : 'auto'; + document.body.style.backgroundColor = + mode === 'fullscreen' ? 'rgba(255, 255, 255, 0.95)' : 'white'; + }, [mode]); + + if (mode === 'fullscreen') { + return ; + } else { + return ; + } +} diff --git a/src/pages/progress-connect/components/AppFullScreen.tsx b/src/pages/progress-connect/components/AppFullScreen.tsx new file mode 100644 index 00000000..feae7825 --- /dev/null +++ b/src/pages/progress-connect/components/AppFullScreen.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useState } from '@/pages/progress-connect/context'; +import { useBrowser } from '@/popup/lib/context'; +import { Steps } from './Steps'; + +export function AppFullscreen() { + const browser = useBrowser(); + const state = useState(); + + const Logo = browser.runtime.getURL('assets/images/logo.svg'); + + return ( +
+
+ +

Web Monetization

+

Connecting wallet…

+
+
+ +

{state.currentStep.name}…

+
+
+ ); +} diff --git a/src/pages/progress-connect/components/AppNotification.tsx b/src/pages/progress-connect/components/AppNotification.tsx new file mode 100644 index 00000000..6ea622cf --- /dev/null +++ b/src/pages/progress-connect/components/AppNotification.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { HeaderEmpty } from '@/popup/components/layout/HeaderEmpty'; +import { LoadingSpinner } from '@/popup/components/LoadingSpinner'; +import { Countdown } from '@/pages/progress-connect/components/Countdown'; +import { useState } from '@/pages/progress-connect/context'; +import { useBrowser } from '@/popup/lib/context'; + +export function AppNotification() { + const browser = useBrowser(); + const { currentStep } = useState(); + + const logo = browser.runtime.getURL('assets/images/logo.svg'); + + return ( +
+ +
+
+ +

+ {currentStep.name}… + {currentStep.status === 'active' && currentStep.expiresAt && ( + + )} +

+
+
+ ); +} diff --git a/src/pages/progress-connect/components/Countdown.tsx b/src/pages/progress-connect/components/Countdown.tsx new file mode 100644 index 00000000..f3446084 --- /dev/null +++ b/src/pages/progress-connect/components/Countdown.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { cn } from '@/shared/helpers'; + +export function Countdown({ + expiresAt, + className, +}: { + expiresAt: number; + className?: string; +}) { + const timer = useCountdown(expiresAt); + + return ( + + {timer[0]}:{timer[1]} + + ); +} + +export function useCountdown(expiresAt: number) { + const getMinuteAndSecond = (deadline: number) => { + const distance = deadline - Date.now(); + if (distance < 0) { + return ['00', '00'] as const; + } + const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((distance % (1000 * 60)) / 1000); + return [ + minutes.toString().padStart(2, '0'), + seconds.toString().padStart(2, '0'), + ] as const; + }; + + const [value, setValue] = React.useState(getMinuteAndSecond(expiresAt)); + + React.useEffect(() => { + let requestId: ReturnType; + + const tick = () => { + const val = getMinuteAndSecond(expiresAt); + setValue(val); + requestId = requestAnimationFrame(tick); + if (val[0] === '00' && val[1] === '00') { + cancelAnimationFrame(requestId); + } + }; + + requestId = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(requestId); + }; + }, [expiresAt]); + + return value; +} diff --git a/src/pages/progress-connect/components/Steps.tsx b/src/pages/progress-connect/components/Steps.tsx new file mode 100644 index 00000000..b380c71d --- /dev/null +++ b/src/pages/progress-connect/components/Steps.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { cn } from '@/shared/helpers'; +import type { StepWithStatus } from '@/content/keyAutoAdd/lib/types'; + +export function Steps({ steps }: { steps: StepWithStatus[] }) { + return ( +
+ {steps.map((step) => ( + + ))} +
+ ); +} + +function Step({ step }: { step: StepWithStatus }) { + return ( +
+ ); +} diff --git a/src/pages/progress-connect/context.tsx b/src/pages/progress-connect/context.tsx new file mode 100644 index 00000000..d8484ad0 --- /dev/null +++ b/src/pages/progress-connect/context.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import type { Runtime } from 'webextension-polyfill'; +import { useBrowser } from '@/popup/lib/context'; +import type { + KeyAutoAddToBackgroundMessage, + StepWithStatus, +} from '@/content/keyAutoAdd/lib/types'; +import { CONNECTION_NAME } from '@/background/services/keyAutoAdd'; + +type State = { + currentStep: StepWithStatus; + steps: StepWithStatus[]; +}; + +type OnPortMessageListener = Parameters< + Runtime.Port['onMessage']['addListener'] +>[0]; + +const StateContext = React.createContext({ + currentStep: { name: '', status: 'active' }, + steps: [], +}); + +export const StateContextProvider = ({ children }: React.PropsWithChildren) => { + const browser = useBrowser(); + const [state, setState] = React.useState({ + currentStep: { name: '', status: 'active' }, + steps: [], + }); + + React.useEffect(() => { + const onMessage: OnPortMessageListener = ( + message: KeyAutoAddToBackgroundMessage, + ) => { + if (message.action === 'PROGRESS') { + const { steps } = message.payload; + const currentStep = getCurrentStep(steps); + setState({ + currentStep: currentStep || { name: '', status: 'pending' }, + steps: steps, + }); + } + }; + + const port = browser.runtime.connect({ name: CONNECTION_NAME }); + port.onMessage.addListener(onMessage); + return () => { + port.disconnect(); + }; + }, [browser]); + + return ( + {children} + ); +}; + +function getCurrentStep(steps: Readonly) { + return steps + .slice() + .reverse() + .find((step) => step.status !== 'pending'); +} + +export const useState = () => React.useContext(StateContext); + +type UIMode = 'notification' | 'fullscreen'; +const UIModeContext = React.createContext('notification'); +export const UIModeProvider = ({ children }: React.PropsWithChildren) => { + const [mode, setMode] = React.useState('notification'); + + React.useEffect(() => { + const onHashChange = () => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const mode = params.get('mode'); + if (mode === 'fullscreen' || mode === 'notification') { + setMode(mode); + } + }; + onHashChange(); + window.addEventListener('hashchange', onHashChange); + return () => { + window.removeEventListener('hashchange', onHashChange); + }; + }, []); + + return ( + {children} + ); +}; + +export const useUIMode = () => React.useContext(UIModeContext); diff --git a/src/pages/progress-connect/index.css b/src/pages/progress-connect/index.css new file mode 100644 index 00000000..4c8f633b --- /dev/null +++ b/src/pages/progress-connect/index.css @@ -0,0 +1,39 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Text colors */ + --text-primary: 59 130 246; + --text-weak: 100 116 139; + --text-medium: 51 65 85; + --text-strong: 15 23 42; + --text-error: 239 68 68; + --text-disabled: 148 163 184; + + /* Background colors */ + --bg-primary: 59 130 246; + --bg-nav-active: 226 232 240; + --bg-error: 254 226 226; + --bg-error-hover: 254 202 202; + --bg-button-base: 86 183 181; + --bg-button-base-hover: 52 152 152; + --bg-switch-base: 152 225 208; + --bg-disabled: 248 250 252; + --bg-disabled-strong: 203 213 225; + + /* Border colors */ + --border-base: 203 213 225; + --border-focus: 59 130 246; + --border-error: 220 38 38; + + /* Popup */ + --popup-width: 448px; + --popup-height: 600px; + } + + body { + @apply bg-white text-base text-medium; + } +} diff --git a/src/pages/progress-connect/index.html b/src/pages/progress-connect/index.html new file mode 100644 index 00000000..3f8df81e --- /dev/null +++ b/src/pages/progress-connect/index.html @@ -0,0 +1,13 @@ + + + + + Web Monetization Extension - Connecting… + + + + + +
+ + diff --git a/src/pages/progress-connect/index.tsx b/src/pages/progress-connect/index.tsx new file mode 100755 index 00000000..dbed1cab --- /dev/null +++ b/src/pages/progress-connect/index.tsx @@ -0,0 +1,25 @@ +import './index.css'; + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import browser from 'webextension-polyfill'; + +import { + BrowserContextProvider, + TranslationContextProvider, +} from '@/popup/lib/context'; +import { StateContextProvider, UIModeProvider } from './context'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('container')!); +root.render( + + + + + + + + + , +); diff --git a/src/popup/components/layout/Header.tsx b/src/popup/components/layout/Header.tsx index e186a95b..cf7ede96 100644 --- a/src/popup/components/layout/Header.tsx +++ b/src/popup/components/layout/Header.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Link, useLocation } from 'react-router-dom'; import { ArrowBack, Settings } from '../Icons'; +import { HeaderEmpty } from './HeaderEmpty'; import { ROUTES_PATH } from '@/popup/Popup'; import { useBrowser, usePopupState } from '@/popup/lib/context'; @@ -37,14 +38,8 @@ export const Header = () => { const Logo = browser.runtime.getURL('assets/images/logo.svg'); return ( -
-
- Web Monetization Logo -

Web Monetization

-
-
- -
-
+ + + ); }; diff --git a/src/popup/components/layout/HeaderEmpty.tsx b/src/popup/components/layout/HeaderEmpty.tsx new file mode 100644 index 00000000..0b7b9487 --- /dev/null +++ b/src/popup/components/layout/HeaderEmpty.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export const HeaderEmpty = ({ + logo, + children, +}: React.PropsWithChildren<{ logo: string }>) => { + return ( +
+
+ Web Monetization Logo +

Web Monetization

+
+
{children}
+
+ ); +}; diff --git a/tsconfig.json b/tsconfig.json index 58c4a44b..8afd23d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "paths": { "@/shared/*": ["./shared/*"], "@/popup/*": ["./popup/*"], + "@/pages/*": ["./pages/*"], "@/background/*": ["./background/*"], "@/content/*": ["./content/*"], "@/assets/*": ["./assets/*"]