import { useState } from "react"; import { Navigate, useLocation, useNavigate } from "react-router"; import React from "react"; import { toast } from "sonner"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import Loading from "src/components/Loading"; import { appStoreApps } from "src/components/connections/SuggestedAppData"; import PasswordInput from "src/components/password/PasswordInput"; import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "src/components/ui/alert-dialog"; import { Button } from "src/components/ui/button"; import { Card, CardContent } from "src/components/ui/card"; import { LinkButton } from "src/components/ui/custom/link-button"; import { LoadingButton } from "src/components/ui/custom/loading-button"; import { Label } from "src/components/ui/label"; import { useCapabilities } from "src/hooks/useCapabilities"; import { createApp } from "src/requests/createApp"; import { App, AppPermissions, BudgetRenewalType, CreateAppRequest, CreateAppResponse, Nip47NotificationType, Nip47RequestMethod, READ_ONLY_SCOPES, Scope, WalletCapabilities, validBudgetRenewals, } from "src/types"; import AppHeader from "src/components/AppHeader"; import { IsolatedAppTopupDialog } from "src/components/IsolatedAppTopupDialog"; import { InstallApp } from "src/components/connections/InstallApp"; import { defineStepper } from "src/components/stepper"; import { Checkbox } from "src/components/ui/checkbox"; import { Input } from "src/components/ui/input"; import { DEFAULT_APP_BUDGET_RENEWAL, DEFAULT_APP_BUDGET_SATS, } from "src/constants"; import { useApp } from "src/hooks/useApp"; import { ConnectAppCard } from "src/screens/apps/ConnectAppCard"; import { handleRequestError } from "src/utils/handleRequestError"; import { safeReturnToUrl } from "src/utils/safeReturnToUrl"; import Permissions from "../../components/Permissions"; import { AppStoreApp } from "../../components/connections/SuggestedAppData"; const NewApp = () => { const { data: capabilities } = useCapabilities(); if (!capabilities) { return ; } return ; }; type NewAppInternalProps = { capabilities: WalletCapabilities; }; const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const location = useLocation(); const [unsupportedError, setUnsupportedError] = useState(); const [isLoading, setLoading] = React.useState(false); const [createAppResponse, setCreateAppResponse] = React.useState(); const queryParams = new URLSearchParams(location.search); const appId = queryParams.get("app") ?? ""; const appStoreApp = appStoreApps.find((app) => app.id === appId); const isInstallable = appStoreApp?.appleLink || appStoreApp?.playLink || appStoreApp?.zapStoreLink || appStoreApp?.chromeLink || appStoreApp?.firefoxLink; const pubkey = queryParams.get("pubkey") ?? ""; const returnTo = safeReturnToUrl(queryParams.get("return_to")) ?? ""; const nameParam = queryParams.get("name") || queryParams.get("c"); const [appName, setAppName] = useState(nameParam || appStoreApp?.title || ""); const budgetRenewalParam = queryParams.get( "budget_renewal" ) as BudgetRenewalType; const budgetMaxAmountMsatParam = queryParams.get("max_amount") ?? ""; const isolatedParam = queryParams.get("isolated") ?? ""; const expiresAtParam = queryParams.get("expires_at") ?? ""; const reqMethodsParam = queryParams.get("request_methods") ?? ""; const notificationTypesParam = queryParams.get("notification_types") ?? ""; /* eslint-disable react-hooks/preserve-manual-memoization */ const initialScopes: Scope[] = React.useMemo(() => { // Receive-only app store apps (e.g. merchant payment receivers) default to // read-only permissions, unless the deep link explicitly requests methods. if (appStoreApp?.readonly && !reqMethodsParam) { return capabilities.scopes.filter((scope) => READ_ONLY_SCOPES.includes(scope) ); } const methods = reqMethodsParam ? reqMethodsParam.split(" ") : capabilities.methods; const requestMethodsSet = new Set( methods as Nip47RequestMethod[] ); const unsupportedMethods = Array.from(requestMethodsSet).filter( (method) => capabilities.methods.indexOf(method) < 0 ); if (unsupportedMethods.length) { // eslint-disable-next-line react-hooks/set-state-in-render setUnsupportedError( "This app requests methods not supported by your wallet: " + unsupportedMethods ); } const notificationTypes = notificationTypesParam ? notificationTypesParam.split(" ") : reqMethodsParam ? [] // do not set notifications if only request methods provided : capabilities.notificationTypes; const notificationTypesSet = new Set( notificationTypes as Nip47NotificationType[] ); const unsupportedNotificationTypes = Array.from( notificationTypesSet ).filter( (notificationType) => capabilities.notificationTypes.indexOf(notificationType) < 0 ); if (unsupportedNotificationTypes.length) { // eslint-disable-next-line react-hooks/set-state-in-render setUnsupportedError( "This app requests notification types not supported by your wallet: " + unsupportedNotificationTypes ); } const scopes: Scope[] = []; if ( requestMethodsSet.has("pay_invoice") || requestMethodsSet.has("pay_keysend") || requestMethodsSet.has("multi_pay_invoice") || requestMethodsSet.has("multi_pay_keysend") ) { scopes.push("pay_invoice"); } if (requestMethodsSet.has("get_info")) { scopes.push("get_info"); } if (requestMethodsSet.has("get_balance")) { scopes.push("get_balance"); } if ( requestMethodsSet.has("make_invoice") || requestMethodsSet.has("make_hold_invoice") || requestMethodsSet.has("settle_hold_invoice") || requestMethodsSet.has("cancel_hold_invoice") ) { scopes.push("make_invoice"); } if (requestMethodsSet.has("lookup_invoice")) { scopes.push("lookup_invoice"); } if (requestMethodsSet.has("list_transactions")) { scopes.push("list_transactions"); } if (requestMethodsSet.has("sign_message") && isolatedParam !== "true") { scopes.push("sign_message"); } if (notificationTypes.length) { scopes.push("notifications"); } return scopes; }, [ appStoreApp?.readonly, capabilities.methods, capabilities.notificationTypes, capabilities.scopes, isolatedParam, notificationTypesParam, reqMethodsParam, ]); /* eslint-enable react-hooks/preserve-manual-memoization */ const parseExpiresParam = (expiresParam: string): Date | undefined => { const expiresParamTimestamp = parseInt(expiresParam); if (!isNaN(expiresParamTimestamp)) { const expiry = new Date(expiresParamTimestamp * 1000); expiry.setHours(23, 59, 59); return expiry; } return undefined; }; const [superuser, setSuperuser] = useState(appStoreApp?.superuser || false); const [ showSuperuserConfirmPasswordDialog, setShowSuperuserConfirmPasswordDialog, ] = useState(false); const [unlockPassword, setUnlockPassword] = useState(""); const [permissions, setPermissions] = useState({ scopes: initialScopes, maxAmountSat: budgetMaxAmountMsatParam ? Math.floor(parseInt(budgetMaxAmountMsatParam) / 1000) : DEFAULT_APP_BUDGET_SATS, budgetRenewal: validBudgetRenewals.includes(budgetRenewalParam) ? budgetRenewalParam : budgetMaxAmountMsatParam ? "never" : DEFAULT_APP_BUDGET_RENEWAL, expiresAt: parseExpiresParam(expiresAtParam), isolated: isolatedParam === "true", }); const { Stepper } = React.useMemo( () => defineStepper( ...(appStoreApp ? [ { id: "install", title: "", }, ] : []), { id: "configure", title: "Configure", }, ...(returnTo ? [] : [{ id: "finalize", title: "Finalize" }]) ), [appStoreApp, returnTo] ); const handleCreateApp = async (nextFunc: () => void) => { if (!permissions.scopes.length) { toast("Please specify wallet permissions."); return; } setLoading(true); try { const createAppRequest: CreateAppRequest = { name: appName, pubkey, budgetRenewal: permissions.budgetRenewal, maxAmountSat: permissions.maxAmountSat || 0, scopes: [ ...permissions.scopes, ...(superuser ? ["superuser" satisfies Scope] : []), ] as Scope[], expiresAt: permissions.expiresAt?.toISOString(), returnTo: returnTo, isolated: permissions.isolated, metadata: { app_store_app_id: appStoreApp?.id, }, unlockPassword, }; const createAppResponse = await createApp(createAppRequest); // dispatch a success event which can be listened to by the opener or by the app that embedded the webview // this gives those apps the chance to know the user has enabled the connection const nwcEvent = new CustomEvent("nwc:success", { detail: { relayUrl: createAppResponse.relayUrls[0], // TODO: deprecate relayUrls: createAppResponse.relayUrls, // TODO: add to spec walletPubkey: createAppResponse.walletPubkey, lud16: createAppResponse.lud16, }, }); window.dispatchEvent(nwcEvent); // notify the opener of the successful connection if (window.opener) { window.opener.postMessage( { type: "nwc:success", relayUrl: createAppResponse.relayUrls[0], // TODO: deprecate relayUrls: createAppResponse.relayUrls, // TODO: add to spec walletPubkey: createAppResponse.walletPubkey, lud16: createAppResponse.lud16, }, "*" ); } const returnToUrl = safeReturnToUrl(createAppResponse.returnTo); if (returnToUrl) { // open connection URI directly in an app // eslint-disable-next-line react-hooks/immutability window.location.href = returnToUrl; return; } toast("App created"); setCreateAppResponse(createAppResponse); nextFunc(); } catch (error) { handleRequestError("Failed to create app", error); } setLoading(false); }; if (unsupportedError) { return ( <>

Try the Alby Hub LDK backend for extra features.

); } return ( <> ) : undefined } description="Configure wallet permissions for the app and follow instructions to finalize the connection" /> {({ methods }) => ( <> {methods.state.all.map((step) => ( methods.state.current.data.id === "configure" && step.id === "install" ? methods.navigation.goTo(step.id) : undefined } > {step.title || (isInstallable ? "Install" : "Open") + " " + appName} {methods.flow.when(step.id, () => ( <> {methods.flow.switch({ install: () => appStoreApp && ( ), configure: () => (
{ e.preventDefault(); if (superuser) { setShowSuperuserConfirmPasswordDialog(true); return; } handleCreateApp(() => methods.navigation.next()); }} > { handleCreateApp(() => methods.navigation.next() ); }} unlockPassword={unlockPassword} setUnlockPassword={setUnlockPassword} /> {!appStoreApp && (
setAppName(e.target.value)} required autoComplete="off" />

Name of the app or purpose of the connection

)}
{appStoreApp?.superuser && (
setSuperuser(!superuser) } className="mt-0.5" />
)} {returnTo && (

You will automatically return to {returnTo}

)} ), finalize: () => createAppResponse && (
), })} {(!methods.state.isLast || returnTo) && ( {!methods.state.isFirst && ( )} methods.navigation.next() } > {step.id === "configure" && pubkey ? "Connect" : "Next"} )} ))}
))}
)}
); }; export default NewApp; function FinalizeConnection({ createAppResponse, appStoreApp, }: { createAppResponse: CreateAppResponse; appStoreApp: AppStoreApp | undefined; }) { const navigate = useNavigate(); const pairingUri = createAppResponse.pairingUri; const hasPairingSecret = !!createAppResponse.pairingSecretKey; const { data: app } = useApp(createAppResponse.id, true); React.useEffect(() => { if (app?.lastUsedAt) { toast("Connection established!", { description: "You can now use the app with your Alby Hub.", }); navigate(`/apps/${createAppResponse.id}`); } }, [app?.lastUsedAt, createAppResponse.id, navigate]); if (!createAppResponse) { return ; } if (!hasPairingSecret) { return ; } return ( <>
{appStoreApp ? ( <>{appStoreApp.finalizeGuide} ) : (
  1. Open the app you wish to connect to
  2. Find settings to connect your wallet (may be under{" "} Nostr Wallet Connect or{" "} NWC).
  3. Scan or paste the connection secret
)} {app?.isolated && (
  • Optional: Top up sub-wallet balance ( ){" "}
  • )} {app && ( )}
    ); } function WaitingForConnection({ app }: { app: App | undefined }) { const [timeout, setTimeout] = React.useState(false); React.useEffect(() => { const timeoutId = window.setTimeout(() => { setTimeout(true); }, 30000); return () => window.clearTimeout(timeoutId); }, []); return (

    Waiting for connection

    Make your first request to complete the connection.

    {timeout && app && (
    Connecting is taking longer than usual. Continue anyway
    )}
    ); } type SuperuserConfirmPasswordDialogProps = { open: boolean; setOpen: (open: boolean) => void; onSubmit: () => void; unlockPassword: string; setUnlockPassword: (password: string) => void; }; function SuperuserConfirmPasswordDialog({ open, setOpen, onSubmit, unlockPassword, setUnlockPassword, }: SuperuserConfirmPasswordDialogProps) { return (
    { e.preventDefault(); onSubmit(); }} > Confirm New Connection

    Alby Go will be given permission to create other app connections which can spend your balance.

    Warning: Alby Go can create connections with a larger budget than the one set for Alby Go. Make sure to always set a budget.

    Please enter your unlock password to continue.

    setOpen(false)}> Cancel
    ); }