diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index c143858d..4e2dbf0d 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -439,6 +439,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient. nil, scopes, false, + nil, ) if err != nil { diff --git a/api/api.go b/api/api.go index 274c5f4f..edaff3e5 100644 --- a/api/api.go +++ b/api/api.go @@ -75,7 +75,8 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons createAppRequest.BudgetRenewal, expiresAt, createAppRequest.Scopes, - createAppRequest.Isolated) + createAppRequest.Isolated, + createAppRequest.Metadata) if err != nil { return nil, err @@ -220,6 +221,16 @@ func (api *api) GetApp(dbApp *db.App) *App { maxAmount := uint64(paySpecificPermission.MaxAmountSat) budgetUsage = queries.GetBudgetUsageSat(api.db, &paySpecificPermission) + var metadata Metadata + if dbApp.Metadata != nil { + jsonErr := json.Unmarshal(dbApp.Metadata, &metadata) + if jsonErr != nil { + logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{ + "app_id": dbApp.ID, + }).Error("Failed to deserialize app metadata") + } + } + response := App{ ID: dbApp.ID, Name: dbApp.Name, @@ -233,6 +244,7 @@ func (api *api) GetApp(dbApp *db.App) *App { BudgetUsage: budgetUsage, BudgetRenewal: paySpecificPermission.BudgetRenewal, Isolated: dbApp.Isolated, + Metadata: metadata, } if dbApp.Isolated { @@ -300,6 +312,17 @@ func (api *api) ListApps() ([]App, error) { apiApp.LastEventAt = &lastEvent.CreatedAt } + var metadata Metadata + if dbApp.Metadata != nil { + jsonErr := json.Unmarshal(dbApp.Metadata, &metadata) + if jsonErr != nil { + logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{ + "app_id": dbApp.ID, + }).Error("Failed to deserialize app metadata") + } + apiApp.Metadata = metadata + } + apiApps = append(apiApps, apiApp) } return apiApps, nil diff --git a/api/models.go b/api/models.go index f7583030..fd01e69d 100644 --- a/api/models.go +++ b/api/models.go @@ -70,6 +70,7 @@ type App struct { BudgetRenewal string `json:"budgetRenewal"` Isolated bool `json:"isolated"` Balance uint64 `json:"balance"` + Metadata Metadata `json:"metadata,omitempty"` } type ListAppsResponse struct { @@ -93,6 +94,7 @@ type CreateAppRequest struct { Scopes []string `json:"scopes"` ReturnTo string `json:"returnTo"` Isolated bool `json:"isolated"` + Metadata Metadata `json:"metadata,omitempty"` } type StartRequest struct { diff --git a/db/db_service.go b/db/db_service.go index 9cca9c72..32753ba6 100644 --- a/db/db_service.go +++ b/db/db_service.go @@ -2,6 +2,7 @@ package db import ( "encoding/hex" + "encoding/json" "errors" "fmt" "slices" @@ -11,6 +12,7 @@ import ( "github.com/getAlby/hub/events" "github.com/getAlby/hub/logger" "github.com/nbd-wtf/go-nostr" + "gorm.io/datatypes" "gorm.io/gorm" ) @@ -26,14 +28,10 @@ func NewDBService(db *gorm.DB, eventPublisher events.EventPublisher) *dbService } } -func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error) { - if isolated && (slices.Contains(scopes, constants.GET_INFO_SCOPE)) { - // cannot return node info because the isolated app is a custodial subaccount - return nil, "", errors.New("Isolated app cannot have get_info scope") - } +func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*App, string, error) { if isolated && (slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE)) { // cannot sign messages because the isolated app is a custodial subaccount - return nil, "", errors.New("Isolated app cannot have sign_message scope") + return nil, "", errors.New("isolated app cannot have sign_message scope") } var pairingPublicKey string @@ -51,7 +49,17 @@ func (svc *dbService) CreateApp(name string, pubkey string, maxAmountSat uint64, } } - app := App{Name: name, NostrPubkey: pairingPublicKey, Isolated: isolated} + var metadataBytes []byte + if metadata != nil { + var err error + metadataBytes, err = json.Marshal(metadata) + if err != nil { + logger.Logger.WithError(err).Error("Failed to serialize metadata") + return nil, "", err + } + } + + app := App{Name: name, NostrPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)} err := svc.db.Transaction(func(tx *gorm.DB) error { err := tx.Save(&app).Error diff --git a/db/migrations/202408291715_app_metadata.go b/db/migrations/202408291715_app_metadata.go new file mode 100644 index 00000000..b0d2b063 --- /dev/null +++ b/db/migrations/202408291715_app_metadata.go @@ -0,0 +1,25 @@ +package migrations + +import ( + _ "embed" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var _202408291715_app_metadata = &gormigrate.Migration{ + ID: "202408291715_app_metadata", + Migrate: func(tx *gorm.DB) error { + + if err := tx.Exec(` + ALTER TABLE apps ADD COLUMN metadata JSON; +`).Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/db/migrations/migrate.go b/db/migrations/migrate.go index 89842711..00fd48bb 100644 --- a/db/migrations/migrate.go +++ b/db/migrations/migrate.go @@ -21,6 +21,7 @@ func Migrate(gormDB *gorm.DB) error { _202407262257_remove_invalid_scopes, _202408061737_add_boostagrams_and_use_json, _202408191242_transaction_failure_reason, + _202408291715_app_metadata, }) return m.Migrate() diff --git a/db/models.go b/db/models.go index 869a8f4f..3126ed8c 100644 --- a/db/models.go +++ b/db/models.go @@ -23,6 +23,7 @@ type App struct { CreatedAt time.Time UpdatedAt time.Time Isolated bool + Metadata datatypes.JSON } type AppPermission struct { @@ -86,7 +87,7 @@ type Transaction struct { } type DBService interface { - CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error) + CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*App, string, error) } const ( diff --git a/frontend/package.json b/frontend/package.json index 4c6c0cd8..6f384963 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@getalby/bitcoin-connect-react": "^3.6.2", + "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", diff --git a/frontend/src/assets/suggested-apps/uncle-jim.png b/frontend/src/assets/suggested-apps/uncle-jim.png new file mode 100644 index 00000000..9238fca0 Binary files /dev/null and b/frontend/src/assets/suggested-apps/uncle-jim.png differ diff --git a/frontend/src/components/AppAvatar.tsx b/frontend/src/components/AppAvatar.tsx index 8f200dac..21f4b735 100644 --- a/frontend/src/components/AppAvatar.tsx +++ b/frontend/src/components/AppAvatar.tsx @@ -1,27 +1,48 @@ +import { suggestedApps } from "src/components/SuggestedAppData"; +import UserAvatar from "src/components/UserAvatar"; import { cn } from "src/lib/utils"; +import { App } from "src/types"; type Props = { - appName: string; + app: App; className?: string; }; -export default function AppAvatar({ appName, className }: Props) { +export default function AppAvatar({ app, className }: Props) { + if (app.name === "getalby.com") { + return ; + } + const appStoreApp = app?.metadata?.app_store_app_id + ? suggestedApps.find( + (suggestedApp) => suggestedApp.id === app.metadata?.app_store_app_id + ) + : undefined; + const image = appStoreApp?.logo; + const gradient = - appName + app.name .split("") .map((c) => c.charCodeAt(0)) .reduce((a, b) => a + b, 0) % 10; return (
- - {appName.charAt(0)} - + {image && ( + + )} + {!image && ( + + {app.name.charAt(0)} + + )}
); } diff --git a/frontend/src/components/CloseChannelDialogContent.tsx b/frontend/src/components/CloseChannelDialogContent.tsx index 7c16fa9f..a8afe5c5 100644 --- a/frontend/src/components/CloseChannelDialogContent.tsx +++ b/frontend/src/components/CloseChannelDialogContent.tsx @@ -38,7 +38,6 @@ export function CloseChannelDialogContent({ alias, channel }: Props) { const copy = (text: string) => { copyToClipboard(text, toast); - toast({ title: "Copied to clipboard." }); }; async function closeChannel() { diff --git a/frontend/src/components/SuggestedAppData.tsx b/frontend/src/components/SuggestedAppData.tsx index 886fde12..6b133f84 100644 --- a/frontend/src/components/SuggestedAppData.tsx +++ b/frontend/src/components/SuggestedAppData.tsx @@ -10,6 +10,7 @@ import paperScissorsHodl from "src/assets/suggested-apps/paper-scissors-hodl.png import primal from "src/assets/suggested-apps/primal.png"; import snort from "src/assets/suggested-apps/snort.png"; import stackernews from "src/assets/suggested-apps/stacker-news.png"; +import uncleJim from "src/assets/suggested-apps/uncle-jim.png"; import wavlake from "src/assets/suggested-apps/wavlake.png"; import wherostr from "src/assets/suggested-apps/wherostr.png"; import yakihonne from "src/assets/suggested-apps/yakihonne.png"; @@ -20,7 +21,8 @@ import zappybird from "src/assets/suggested-apps/zappy-bird.png"; export type SuggestedApp = { id: string; - webLink: string; + webLink?: string; + internal?: boolean; playLink?: string; appleLink?: string; title: string; @@ -29,6 +31,13 @@ export type SuggestedApp = { }; export const suggestedApps: SuggestedApp[] = [ + { + id: "uncle-jim", + title: "Friends & Family", + description: "Subaccounts powered by your Hub", + internal: true, + logo: uncleJim, + }, { id: "alby-extension", title: "Alby Extension", diff --git a/frontend/src/components/SuggestedApps.tsx b/frontend/src/components/SuggestedApps.tsx index 1a6b80f9..b412a84e 100644 --- a/frontend/src/components/SuggestedApps.tsx +++ b/frontend/src/components/SuggestedApps.tsx @@ -1,4 +1,4 @@ -import { Globe } from "lucide-react"; +import { ExternalLinkIcon, Globe } from "lucide-react"; import { Link } from "react-router-dom"; import ExternalLink from "src/components/ExternalLink"; import { AppleIcon } from "src/components/icons/Apple"; @@ -36,11 +36,13 @@ function SuggestedAppCard({
- - - + {webLink && ( + + + + )} {appleLink && ( + + + + ); +} + export default function SuggestedApps() { return ( <>
- {suggestedApps.map((app) => ( - - ))} + {suggestedApps.map((app) => + app.internal ? ( + + ) : ( + + ) + )}
); diff --git a/frontend/src/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx index c8a32367..ccbfe3c0 100644 --- a/frontend/src/components/TransactionItem.tsx +++ b/frontend/src/components/TransactionItem.tsx @@ -9,6 +9,7 @@ import { CopyIcon, } from "lucide-react"; import React from "react"; +import { Link } from "react-router-dom"; import AppAvatar from "src/components/AppAvatar"; import PodcastingInfo from "src/components/PodcastingInfo"; import { @@ -38,11 +39,13 @@ function TransactionItem({ tx }: Props) { const [showDetails, setShowDetails] = React.useState(false); const type = tx.type; const Icon = tx.type == "outgoing" ? ArrowUpIcon : ArrowDownIcon; - const app = tx.appId && apps?.find((app) => app.id === tx.appId); + const app = + tx.appId !== undefined + ? apps?.find((app) => app.id === tx.appId) + : undefined; const copy = (text: string) => { copyToClipboard(text, toast); - toast({ title: "Copied to clipboard." }); }; return ( @@ -57,36 +60,40 @@ function TransactionItem({ tx }: Props) { {/* flex wrap is used as a last resort to stop horizontal scrollbar on mobile. */}
- {app ? ( - - ) : ( -
+ - -
- )} + /> + {app && ( +
+ +
+ )} +
-
+

- {app ? app.name : type == "incoming" ? "Received" : "Sent"} + {type == "incoming" ? "Received" : "Sent"}

{dayjs(tx.settledAt).fromNow()} @@ -155,7 +162,23 @@ function TransactionItem({ tx }: Props) {

*/}
-
+ {app && ( +
+

App

+ +
+ +

+ {app.name === "getalby.com" ? "Alby Account" : app.name} +

+
+ +
+ )} +

Date & Time

{dayjs(tx.settledAt) diff --git a/frontend/src/components/connections/AppCard.tsx b/frontend/src/components/connections/AppCard.tsx index 55eb2656..ba18e57d 100644 --- a/frontend/src/components/connections/AppCard.tsx +++ b/frontend/src/components/connections/AppCard.tsx @@ -30,7 +30,7 @@ export default function AppCard({ app }: Props) {

- +
{app.name}
diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx new file mode 100644 index 00000000..8540b2f8 --- /dev/null +++ b/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; + +import { cn } from "src/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 547c999e..843a0439 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -33,6 +33,7 @@ import { OpeningAutoChannel } from "src/screens/channels/auto/OpeningAutoChannel import { FirstChannel } from "src/screens/channels/first/FirstChannel"; import { OpenedFirstChannel } from "src/screens/channels/first/OpenedFirstChannel"; import { OpeningFirstChannel } from "src/screens/channels/first/OpeningFirstChannel"; +import { UncleJimApp } from "src/screens/internal-apps/UncleJimApp"; import { Success } from "src/screens/onboarding/Success"; import BuyBitcoin from "src/screens/onchain/BuyBitcoin"; import DepositBitcoin from "src/screens/onchain/DepositBitcoin"; @@ -187,7 +188,6 @@ const routes = [ index: true, element: , }, - { path: ":pubkey", element: , @@ -203,6 +203,17 @@ const routes = [ }, ], }, + { + path: "internal-apps", + element: , + handle: { crumb: () => "Connections" }, + children: [ + { + path: "uncle-jim", + element: , + }, + ], + }, { path: "appstore", element: , diff --git a/frontend/src/screens/apps/AppCreated.tsx b/frontend/src/screens/apps/AppCreated.tsx index 6143e2e6..ee50e0aa 100644 --- a/frontend/src/screens/apps/AppCreated.tsx +++ b/frontend/src/screens/apps/AppCreated.tsx @@ -6,7 +6,7 @@ import AppHeader from "src/components/AppHeader"; import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading"; import QRCode from "src/components/QRCode"; -import { suggestedApps } from "src/components/SuggestedAppData"; +import { SuggestedApp, suggestedApps } from "src/components/SuggestedAppData"; import { Button } from "src/components/ui/button"; import { Card, @@ -17,7 +17,7 @@ import { import { useToast } from "src/components/ui/use-toast"; import { useApp } from "src/hooks/useApp"; import { copyToClipboard } from "src/lib/clipboard"; -import { CreateAppResponse } from "src/types"; +import { App, CreateAppResponse } from "src/types"; export default function AppCreated() { const { state } = useLocation(); @@ -39,26 +39,11 @@ function AppCreatedInternal() { const appId = queryParams.get("app") ?? ""; const appstoreApp = suggestedApps.find((app) => app.id === appId); - const [timeout, setTimeout] = useState(false); - const [isQRCodeVisible, setIsQRCodeVisible] = useState(false); - const createAppResponse = state as CreateAppResponse; + const pairingUri = createAppResponse.pairingUri; const { data: app } = useApp(createAppResponse.pairingPublicKey, true); - const copy = () => { - copyToClipboard(pairingUri, toast); - toast({ title: "Copied to clipboard." }); - }; - - useEffect(() => { - const timeoutId = window.setTimeout(() => { - setTimeout(true); - }, 30000); - - return () => window.clearTimeout(timeoutId); - }, []); - useEffect(() => { if (app?.lastEventAt) { toast({ @@ -104,7 +89,7 @@ function AppCreatedInternal() {

1. Open{" "} - {appstoreApp ? ( + {appstoreApp?.webLink ? (

2. Scan or paste the connection secret

- - - Connection Secret - - -
- -

Waiting for app to connect

-
- {timeout && ( -
- Connecting is taking longer than usual. - - - -
- )} - -
- - {appstoreApp && ( - - )} -
- {!isQRCodeVisible && ( - - )} -
-
- -
-
-
+ {app && ( + + )}
); } + +export function ConnectAppCard({ + app, + pairingUri, + appstoreApp, +}: { + app: App; + pairingUri: string; + appstoreApp?: SuggestedApp; +}) { + const [timeout, setTimeout] = useState(false); + const [isQRCodeVisible, setIsQRCodeVisible] = useState(false); + const { toast } = useToast(); + const copy = () => { + copyToClipboard(pairingUri, toast); + }; + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setTimeout(true); + }, 30000); + + return () => window.clearTimeout(timeoutId); + }, []); + + return ( + + + Connection Secret + + +
+ +

Waiting for app to connect

+
+ {timeout && ( +
+ Connecting is taking longer than usual. + + + +
+ )} + +
+ + {appstoreApp && ( + + )} +
+ {!isQRCodeVisible && ( + + )} +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/screens/apps/NewApp.tsx b/frontend/src/screens/apps/NewApp.tsx index ffec88bb..b86923e8 100644 --- a/frontend/src/screens/apps/NewApp.tsx +++ b/frontend/src/screens/apps/NewApp.tsx @@ -16,9 +16,9 @@ import { import React from "react"; import AppHeader from "src/components/AppHeader"; import Loading from "src/components/Loading"; -import { Button } from "src/components/ui/button"; import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; +import { LoadingButton } from "src/components/ui/loading-button"; import { Separator } from "src/components/ui/separator"; import { useToast } from "src/components/ui/use-toast"; import { useApps } from "src/hooks/useApps"; @@ -48,17 +48,20 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { const navigate = useNavigate(); const { data: apps } = useApps(); const [unsupportedError, setUnsupportedError] = useState(); + const [isLoading, setLoading] = React.useState(false); const queryParams = new URLSearchParams(location.search); const appId = queryParams.get("app") ?? ""; - const app = suggestedApps.find((app) => app.id === appId); + const appStoreApp = suggestedApps.find((app) => app.id === appId); const pubkey = queryParams.get("pubkey") ?? ""; const returnTo = queryParams.get("return_to") ?? ""; const nameParam = (queryParams.get("name") || queryParams.get("c")) ?? ""; - const [appName, setAppName] = useState(app ? app.title : nameParam); + const [appName, setAppName] = useState( + appStoreApp ? appStoreApp.title : nameParam + ); const budgetRenewalParam = queryParams.get( "budget_renewal" @@ -120,7 +123,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { scopes.push("pay_invoice"); } - if (requestMethodsSet.has("get_info") && isolatedParam !== "true") { + if (requestMethodsSet.has("get_info")) { scopes.push("get_info"); } if (requestMethodsSet.has("get_balance")) { @@ -181,6 +184,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { return; } + setLoading(true); try { if (apps?.some((existingApp) => existingApp.name === appName)) { throw new Error("A connection with the same name already exists."); @@ -195,6 +199,9 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { expiresAt: permissions.expiresAt?.toISOString(), returnTo: returnTo, isolated: permissions.isolated, + metadata: { + app_store_app_id: appStoreApp?.id, + }, }; const createAppResponse = await request("/api/apps", { @@ -214,13 +221,14 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { window.location.href = createAppResponse.returnTo; return; } - navigate(`/apps/created${app ? `?app=${app.id}` : ""}`, { + navigate(`/apps/created${appStoreApp ? `?app=${appStoreApp.id}` : ""}`, { state: createAppResponse, }); toast({ title: "App created" }); } catch (error) { handleRequestError(toast, "Failed to create app", error); } + setLoading(false); }; if (unsupportedError) { @@ -243,10 +251,10 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => { acceptCharset="UTF-8" className="flex flex-col items-start gap-5 max-w-lg" > - {app && ( + {appStoreApp && (
- -

{app.title}

+ +

{appStoreApp.title}

)} {!nameParam && ( @@ -288,7 +296,9 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {

)} - + + {pubkey ? "Connect" : "Next"} + ); diff --git a/frontend/src/screens/apps/ShowApp.tsx b/frontend/src/screens/apps/ShowApp.tsx index 7f405dca..0ebc8c5e 100644 --- a/frontend/src/screens/apps/ShowApp.tsx +++ b/frontend/src/screens/apps/ShowApp.tsx @@ -139,6 +139,8 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { } }; + const appName = app.name === "getalby.com" ? "Alby Account" : app.name; + return ( <>
@@ -146,7 +148,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { - + {isEditingName ? (
setIsEditingName(true)} >

- {app.name} + {appName}

- + {app.name !== "getalby.com" && ( + + )}
)}
diff --git a/frontend/src/screens/internal-apps/UncleJimApp.tsx b/frontend/src/screens/internal-apps/UncleJimApp.tsx new file mode 100644 index 00000000..94ada9db --- /dev/null +++ b/frontend/src/screens/internal-apps/UncleJimApp.tsx @@ -0,0 +1,251 @@ +import { CopyIcon } from "lucide-react"; +import React from "react"; +import AppHeader from "src/components/AppHeader"; +import AppCard from "src/components/connections/AppCard"; +import ExternalLink from "src/components/ExternalLink"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "src/components/ui/accordion"; +import { Button } from "src/components/ui/button"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { Textarea } from "src/components/ui/textarea"; +import { useToast } from "src/components/ui/use-toast"; +import { useApp } from "src/hooks/useApp"; +import { useApps } from "src/hooks/useApps"; +import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo"; +import { copyToClipboard } from "src/lib/clipboard"; +import { ConnectAppCard } from "src/screens/apps/AppCreated"; +import { CreateAppRequest, CreateAppResponse } from "src/types"; +import { handleRequestError } from "src/utils/handleRequestError"; +import { request } from "src/utils/request"; + +export function UncleJimApp() { + const [name, setName] = React.useState(""); + const [appPublicKey, setAppPublicKey] = React.useState(""); + const [connectionSecret, setConnectionSecret] = React.useState(""); + const { data: apps } = useApps(); + const { data: app } = useApp(appPublicKey, true); + const { data: nodeConnectionInfo } = useNodeConnectionInfo(); + const { toast } = useToast(); + const [isLoading, setLoading] = React.useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setLoading(true); + + try { + if (apps?.some((existingApp) => existingApp.name === name)) { + throw new Error("A connection with the same name already exists."); + } + + const createAppRequest: CreateAppRequest = { + name, + scopes: [ + "get_balance", + "get_info", + "list_transactions", + "lookup_invoice", + "make_invoice", + "notifications", + "pay_invoice", + ], + isolated: true, + metadata: { + app_store_app_id: "uncle-jim", + }, + }; + + const createAppResponse = await request("/api/apps", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(createAppRequest), + }); + + if (!createAppResponse) { + throw new Error("no create app response received"); + } + + setConnectionSecret(createAppResponse.pairingUri); + setAppPublicKey(createAppResponse.pairingPublicKey); + + toast({ title: "New subaccount created for " + name }); + } catch (error) { + handleRequestError(toast, "Failed to create app", error); + } + setLoading(false); + }; + + const albyAccountUrl = `https://getalby.com/nwc/new#${connectionSecret}`; + const valueTag = ` + +`; + + const onboardedApps = apps?.filter( + (app) => app.metadata?.app_store_app_id === "uncle-jim" + ); + + return ( +
+ + {!connectionSecret && ( + <> +
+
+ + setName(e.target.value)} + required + autoComplete="off" + placeholder="John Galt" + /> +
+ + Create Subaccount + +
+ + {!!onboardedApps?.length && ( + <> +

+ Great job! You've onboarded {onboardedApps.length} friends and + family members so far. +

+
+ {onboardedApps.map((app, index) => ( + + ))} +
+ + )} + + )} + {connectionSecret && ( +
+

+ Choose how you want to onboard {name} to their new wallet: +

+ + + Alby Go + +

+ 1. Ask {name} to download the Alby Go app from Google Play or + the iOS App Store +

+

+ 2. Ask {name} to scan the below QR code. +

+ {app && ( + + )} +
+
+ + Alby Account + +

+ 1. Send {name} an{" "} + + Alby Account invitation + {" "} + if they don't have one yet. +

+

+ 2. Send {name} the below link which will link the new wallet + to their Alby Account. Do not to share this publicly as it + contains the connection secret for their wallet. +

+
+ + +
+
+
+ + Alby Extension + +

+ 1. Send {name} the below connection secret which they can add + to their Alby Extension by choosing "Bring Your Own Wallet"{" "} + {"->"} "Nostr Wallet Connect" and pasting the connection + secret. Do not to share this publicly as it contains the + connection secret for their wallet. +

+
+ + +
+
+
+ + Podcasting 2.0 + +

+ 1. Make sure to give {name} access to their wallet with one of + the options above. +

+

+ 2. Send them this value tag which they can add to their RSS + feed. +

+
+