This commit is contained in:
benthecarman
2023-08-03 14:48:50 -05:00
committed by Tony Giorgio
parent ccb7f3737e
commit 932c0a1f2b
23 changed files with 779 additions and 114 deletions

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.75 20.9531C3.75 21.368 4.08516 21.7031 4.5 21.7031H11.2031V12.8906H3.75V20.9531ZM12.7969 21.7031H19.5C19.9148 21.7031 20.25 21.368 20.25 20.9531V12.8906H12.7969V21.7031ZM20.625 7.26562H17.1656C17.4844 6.76406 17.6719 6.16875 17.6719 5.53125C17.6719 3.74766 16.2211 2.29688 14.4375 2.29688C13.4672 2.29688 12.593 2.72812 12 3.40781C11.407 2.72812 10.5328 2.29688 9.5625 2.29688C7.77891 2.29688 6.32812 3.74766 6.32812 5.53125C6.32812 6.16875 6.51328 6.76406 6.83438 7.26562H3.375C2.96016 7.26562 2.625 7.60078 2.625 8.01562V11.2969H11.2031V7.26562H12.7969V11.2969H21.375V8.01562C21.375 7.60078 21.0398 7.26562 20.625 7.26562ZM11.2031 7.17188H9.5625C8.65781 7.17188 7.92188 6.43594 7.92188 5.53125C7.92188 4.62656 8.65781 3.89062 9.5625 3.89062C10.4672 3.89062 11.2031 4.62656 11.2031 5.53125V7.17188ZM14.4375 7.17188H12.7969V5.53125C12.7969 4.62656 13.5328 3.89062 14.4375 3.89062C15.3422 3.89062 16.0781 4.62656 16.0781 5.53125C16.0781 6.43594 15.3422 7.17188 14.4375 7.17188Z" fill="#FA0050"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
src/assets/treasure.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

View File

@@ -564,7 +564,8 @@ export const AmountEditable: ParentComponent<{
}
}
function handleClose() {
function handleClose(e: SubmitEvent | MouseEvent | KeyboardEvent) {
e.preventDefault();
props.setAmountSats(BigInt(props.initialAmountSats));
setIsOpen(false);
setLocalSats(props.initialAmountSats);
@@ -576,6 +577,7 @@ export const AmountEditable: ParentComponent<{
)
);
props.exitRoute && navigate(props.exitRoute);
return false;
}
// What we're all here for in the first place: returning a value
@@ -586,6 +588,7 @@ export const AmountEditable: ParentComponent<{
satsToFiat(state.price, Number(localSats()) || 0, state.fiat)
);
setIsOpen(false);
return false;
}
function handleSatsInput(e: InputEvent) {
@@ -706,6 +709,7 @@ export const AmountEditable: ParentComponent<{
<div class="flex w-full justify-end">
<button
onClick={handleClose}
type="button"
class="h-8 w-8 rounded-lg hover:bg-white/10 active:bg-m-blue"
>
<img src={close} alt="Close" />

View File

@@ -1,27 +0,0 @@
import { Show } from "solid-js";
import { QRCodeSVG } from "solid-qr-code";
import { useI18n } from "~/i18n/context";
import { useCopy } from "~/utils";
export function CopyableQR(props: { value: string }) {
const i18n = useI18n();
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return (
<div
id="qr"
class="relative w-full rounded-xl bg-white"
onClick={() => copy(props.value)}
>
<Show when={copied()}>
<div class="absolute z-50 flex h-full w-full flex-col items-center justify-center rounded-xl bg-neutral-900/60 transition-all">
<p class="text-xl font-bold">{i18n.t("common.copied")}</p>
</div>
</Show>
<QRCodeSVG
value={props.value}
class="h-full max-h-[400px] w-full p-8"
/>
</div>
);
}

View File

@@ -65,7 +65,7 @@ export const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm";
export const DIALOG_POSITIONER =
"fixed inset-0 z-50 flex items-center justify-center";
export const DIALOG_CONTENT =
"max-w-[500px] w-[90vw] max-h-[100dvh] overflow-y-scroll disable-scrollbars mx-4 p-4 bg-neutral-800/80 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
"max-w-[500px] w-[90vw] max-h-[100dvh] overflow-y-scroll disable-scrollbars mx-4 p-4 bg-m-grey-800/75 backdrop-blur-md shadow-xl rounded-xl border border-white/10";
function LightningHeader(props: {
info: MutinyInvoice;

View File

@@ -0,0 +1,22 @@
import { A, useLocation } from "solid-start";
import gift from "~/assets/icons/gift.svg";
import { useI18n } from "~/i18n/context";
export function GiftLink() {
const i18n = useI18n();
const location = useLocation();
return (
<A
class="flex items-center gap-2 font-semibold text-m-red no-underline"
href="/settings/gift"
state={{
previous: location.pathname
}}
>
{i18n.t("settings.gift.give_sats_link")}
<img src={gift} class="h-5 w-5" alt="Gift" />
</A>
);
}

View File

@@ -10,7 +10,7 @@ import { useI18n } from "~/i18n/context";
import { ReceiveFlavor } from "~/routes/Receive";
import { useCopy } from "~/utils";
function KindIndicator(props: { kind: ReceiveFlavor }) {
function KindIndicator(props: { kind: ReceiveFlavor | "gift" }) {
const i18n = useI18n();
return (
<div class="flex flex-col items-end text-black">
@@ -29,6 +29,13 @@ function KindIndicator(props: { kind: ReceiveFlavor }) {
<img src={boltBlack} alt="bolt" />
</Match>
<Match when={props.kind === "gift"}>
<h3 class="font-semibold">
{i18n.t("receive.integrated_qr.gift")}
</h3>
<img src={boltBlack} alt="bolt" />
</Match>
<Match when={props.kind === "unified"}>
<h3 class="font-semibold">
{i18n.t("receive.integrated_qr.unified")}
@@ -62,7 +69,7 @@ async function share(receiveString: string) {
export function IntegratedQr(props: {
value: string;
amountSats: string;
kind: ReceiveFlavor;
kind: ReceiveFlavor | "gift";
}) {
const i18n = useI18n();
const [copy, copied] = useCopy({ copiedTimeout: 1000 });

29
src/components/Logo.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Match, Switch } from "solid-js";
import pixelLogo from "~/assets/mutiny-pixel-logo.png";
import plusLogo from "~/assets/mutiny-plus-logo.png";
import { useMegaStore } from "~/state/megaStore";
export function Logo() {
const [state, _actions] = useMegaStore();
return (
<Switch>
<Match when={state.mutiny_plus}>
<img
id="mutiny-logo"
src={plusLogo}
class="h-[25px] w-[86px]"
alt="Mutiny Plus logo"
/>
</Match>
<Match when={true}>
<img
id="mutiny-logo"
src={pixelLogo}
class="h-[25px] w-[75px]"
alt="Mutiny logo"
/>
</Match>
</Switch>
);
}

View File

@@ -104,7 +104,7 @@ export function NWCBudgetEditor(props: {
<TinyText>
{i18n.t("settings.connections.careful")}
</TinyText>
<KeyValue key={"Budget"}>
<KeyValue key={i18n.t("settings.connections.budget")}>
<Field name="budget_amount">
{(field, _fieldProps) => (
<div class="flex flex-col items-end gap-2">
@@ -129,7 +129,9 @@ export function NWCBudgetEditor(props: {
)}
</Field>
</KeyValue>
<KeyValue key={"Resets every"}>
<KeyValue
key={i18n.t("settings.connections.resets_every")}
>
<Field name="interval">
{(field, fieldProps) => (
<select

View File

@@ -4,6 +4,7 @@ import airplane from "~/assets/icons/airplane.svg";
import receive from "~/assets/icons/big-receive.svg";
import mutiny_m from "~/assets/icons/m.svg";
import redshift from "~/assets/icons/rs.svg";
import scan from "~/assets/icons/scan.svg";
import settings from "~/assets/icons/settings.svg";
import userClock from "~/assets/icons/user-clock.svg";
@@ -67,6 +68,12 @@ export function NavBar(props: { activeTab: ActiveTab }) {
active={props.activeTab === "activity"}
alt="activity"
/>
<NavBarItem
href="/scanner"
icon={scan}
active={false}
alt="scan"
/>
<NavBarItem
href="/redshift"
icon={redshift}

View File

@@ -12,7 +12,6 @@ export * from "./ChooseCurrency";
export * from "./ContactEditor";
export * from "./ContactForm";
export * from "./ContactViewer";
export * from "./CopyableQR";
export * from "./DecryptDialog";
export * from "./DeleteEverything";
export * from "./DetailsModal";
@@ -25,6 +24,7 @@ export * from "./IntegratedQR";
export * from "./JsonModal";
export * from "./KitchenSink";
export * from "./LoadingIndicator";
export * from "./Logo";
export * from "./Logs";
export * from "./MoreInfoModal";
export * from "./NavBar";
@@ -42,3 +42,4 @@ export * from "./TagEditor";
export * from "./Toaster";
export * from "./NostrActivity";
export * from "./SyncContactsForm";
export * from "./GiftLink";

View File

@@ -14,7 +14,10 @@ export function BackPop() {
const state = location.state as StateWithPrevious;
const backPath = () => (state?.previous ? state?.previous : "/");
// If there's no previous state want to just go back one level, basically ../
const newBackPath = location.pathname.split("/").slice(0, -1).join("/");
const backPath = () => (state?.previous ? state?.previous : newBackPath);
return (
<BackButton

View File

@@ -89,7 +89,8 @@ export default {
integrated_qr: {
onchain: "On-chain",
lightning: "Lightning",
unified: "Unified"
unified: "Unified",
gift: "Lightning Gift"
},
remember_choice: "Remember my choice next time"
},
@@ -269,6 +270,7 @@ export default {
error_budget_zero: "Budget must be greater than zero",
add_connection: "Add Connection",
manage_connections: "Manage Connections",
manage_gifts: "Manage Gifts",
delete_connection: "Delete",
new_connection: "New Connection",
edit_connection: "Edit Connection",
@@ -292,7 +294,9 @@ export default {
"Be careful where you share this connection! Requests within budget will paid automatically.",
spent: "Spent",
remaining: "Remaining",
confirm_delete: "Are you sure you want to delete this connection?"
confirm_delete: "Are you sure you want to delete this connection?",
budget: "Budget",
resets_every: "Resets every"
},
emergency_kit: {
title: "Emergency Kit",
@@ -448,6 +452,41 @@ export default {
npub_label: "Nostr npub",
npub_required: "Npub can't be blank",
sync: "Sync"
},
gift: {
give_sats_link: "Give sats as a gift",
title: "Gifting",
receive_too_small:
"Your first receive needs to be {{amount}} SATS or greater.",
setup_fee_lightning:
"A lightning setup fee will be charged to receive this gift.",
already_claimed: "This gift has already been claimed",
sender_is_poor:
"The sender doesn't have enough balance to pay this gift.",
sender_generic_error: "Sender sent error: {{error}}",
receive_header: "You've been gifted some sats!",
receive_description:
"You must be pretty special. To claim your money just hit the big button. Funds will be added to your balance the next time your gifter is online.",
receive_claimed:
"Gift claimed! You should see the gift hit your balance shortly.",
receive_cta: "Claim Gift",
send_header: "Create Gift",
send_explainer:
"Give the gift of sats. Create a Mutiny gift URL that can be claimed by anyone with a web browser.",
send_name_required: "This is for your records",
send_name_label: "Recepient Name",
send_header_claimed: "Gift Received!",
send_claimed: "Your gift has been claimed. Thanks for sharing.",
send_sharable_header: "Sharable URL",
send_instructions:
"Copy this gift URL to your recipient, or ask them to scan this QR code with their wallet.",
send_another: "Create Another",
send_small_warning:
"A brand new Mutiny user won't be able to redeem fewer than 50k sats.",
send_cta: "Create a gift",
send_delete_button: "Delete Gift",
send_delete_confirm:
"Are you sure you want to delete this gift? Is this your rugpull moment?"
}
},
swap: {

226
src/routes/Gift.tsx Normal file
View File

@@ -0,0 +1,226 @@
import { createResource, createSignal, Match, Show, Switch } from "solid-js";
import { useSearchParams } from "solid-start";
import treasureClosed from "~/assets/treasure-closed.png";
import treasure from "~/assets/treasure.gif";
import {
AmountFiat,
AmountSats,
Button,
ButtonLink,
DefaultMain,
FancyCard,
FeesModal,
InfoBox,
Logo,
MutinyWalletGuard,
NavBar,
NiceP,
SafeArea,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { Network } from "~/logic/mutinyWalletSetup";
import { useMegaStore } from "~/state/megaStore";
import { eify } from "~/utils";
export default function GiftPage() {
const [state, _] = useMegaStore();
const i18n = useI18n();
const [claimSuccess, setClaimSuccess] = createSignal(false);
const [error, setError] = createSignal<Error>();
const [loading, setLoading] = createSignal(false);
const [searchParams] = useSearchParams();
const [inboundCapacity] = createResource(async () => {
try {
const channels = await state.mutiny_wallet?.list_channels();
let inbound = 0;
for (const channel of channels) {
inbound += channel.size - (channel.balance + channel.reserve);
}
return inbound;
} catch (e) {
console.error(e);
return 0;
}
});
const warningText = () => {
const amount = Number(searchParams.amount);
if (isNaN(amount)) {
return undefined;
}
const network = state.mutiny_wallet?.get_network() as Network;
const threshold = network === "bitcoin" ? 50000 : 10000;
const balance = state.balance?.lightning || 0n;
if (balance === 0n && amount < threshold) {
return i18n.t("settings.gift.receive_too_small", {
amount: network === "bitcoin" ? "50,000" : "10,000"
});
}
if (amount > (inboundCapacity() || 0)) {
return i18n.t("settings.gift.setup_fee_lightning");
}
return undefined;
};
async function claim() {
const amount = Number(searchParams.amount);
const nwc = searchParams.nwc_uri;
setLoading(true);
try {
const claimResult = await state.mutiny_wallet?.claim_single_use_nwc(
BigInt(amount),
nwc
);
console.log("claim result", claimResult);
if (claimResult === "Already Claimed") {
throw new Error(i18n.t("settings.gift.already_claimed"));
}
if (
claimResult ===
"Failed to pay invoice: We do not have enough balance to pay the given amount."
) {
throw new Error(i18n.t("settings.gift.sender_is_poor"));
}
// Fallback for any other errors
if (claimResult) {
throw new Error(
i18n.t("settings.gift.sender_generic_error", {
error: claimResult
})
);
}
setClaimSuccess(true);
} catch (e) {
console.error(e);
setError(eify(e));
} finally {
setLoading(false);
}
}
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<Show when={searchParams.nwc_uri && searchParams.amount}>
<VStack>
<FancyCard>
<VStack>
<div class="flex items-start justify-between">
<VStack smallgap>
<span class="text-3xl">
<AmountSats
denominationSize="xl"
amountSats={Number(
searchParams.amount
)}
/>
</span>
<span class="text-xl text-white/70">
<AmountFiat
denominationSize="xl"
amountSats={Number(
searchParams.amount
)}
/>
</span>
</VStack>
<Logo />
</div>
<div
class="relative transition-all duration-500"
classList={{
"grayscale filter opacity-75":
!claimSuccess()
}}
>
<img
src={treasureClosed}
fetchpriority="high"
class="mx-auto w-1/2"
classList={{
hidden: !!claimSuccess()
}}
/>
<img
src={treasure}
fetchpriority="high"
class="mx-auto w-1/2"
classList={{
hidden: !claimSuccess()
}}
/>
</div>
<h2 class="text-center text-3xl font-semibold">
{i18n.t("settings.gift.receive_header")}
</h2>
<NiceP>
{i18n.t(
"settings.gift.receive_description"
)}
</NiceP>
<Show
when={warningText() && !claimSuccess()}
>
<InfoBox accent="blue">
{warningText()} <FeesModal />
</InfoBox>
</Show>
<Switch>
<Match when={error()}>
<InfoBox accent="red">
{error()?.message}
</InfoBox>
<ButtonLink href="/" intent="red">
{i18n.t("common.dangit")}
</ButtonLink>
</Match>
<Match when={claimSuccess()}>
<InfoBox accent="green">
{i18n.t(
"settings.gift.receive_claimed"
)}
</InfoBox>
<ButtonLink
href="/"
intent="inactive"
>
{i18n.t("common.nice")}
</ButtonLink>
</Match>
<Match when={true}>
<Button
intent="inactive"
onClick={claim}
loading={loading()}
>
{i18n.t(
"settings.gift.receive_cta"
)}
</Button>
</Match>
</Switch>
</VStack>
</FancyCard>
</VStack>
</Show>
</DefaultMain>
<NavBar activeTab="none" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -5,12 +5,11 @@ import { useNavigate } from "solid-start";
import { Button, Scanner as Reader, showToast } from "~/components";
import { useI18n } from "~/i18n/context";
import { toParsedParams } from "~/logic/waila";
import { useMegaStore } from "~/state/megaStore";
export default function Scanner() {
const i18n = useI18n();
const [state, actions] = useMegaStore();
const [_state, actions] = useMegaStore();
const [scanResult, setScanResult] = createSignal<string>();
const navigate = useNavigate();
@@ -44,22 +43,16 @@ export default function Scanner() {
// When we have a nice result we can head over to the send screen
createEffect(() => {
if (scanResult()) {
const network = state.mutiny_wallet?.get_network() || "signet";
const result = toParsedParams(scanResult() || "", network);
if (!result.ok) {
showToast(result.error);
return;
} else {
if (
result.value?.address ||
result.value?.invoice ||
result.value?.node_pubkey ||
result.value?.lnurl
) {
actions.setScanResult(result.value);
actions.handleIncomingString(
scanResult()!,
(error) => {
showToast(error);
},
(result) => {
actions.setScanResult(result);
navigate("/send");
}
}
);
}
});

View File

@@ -26,6 +26,7 @@ import {
DefaultMain,
ExternalLink,
Fee,
GiftLink,
HStack,
InfoBox,
LargeHeader,
@@ -44,12 +45,10 @@ import {
} from "~/components";
import { useI18n } from "~/i18n/context";
import { Network } from "~/logic/mutinyWalletSetup";
import { ParsedParams, toParsedParams } from "~/logic/waila";
import { ParsedParams } from "~/logic/waila";
import { useMegaStore } from "~/state/megaStore";
import { eify, mempoolTxUrl, MutinyTagItem } from "~/utils";
import { FeedbackLink } from "./Feedback";
export type SendSource = "lightning" | "onchain";
// const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl"
@@ -379,25 +378,17 @@ export default function Send() {
});
function parsePaste(text: string) {
if (text) {
const network = state.mutiny_wallet?.get_network() || "signet";
const result = toParsedParams(text || "", network);
if (!result.ok) {
showToast(result.error);
return;
} else {
if (
result.value?.address ||
result.value?.invoice ||
result.value?.node_pubkey ||
result.value?.lnurl
) {
setDestination(result.value);
actions.handleIncomingString(
text,
(error) => {
showToast(error);
},
(result) => {
setDestination(result);
// Important! we need to clear the scan result once we've used it
actions.setScanResult(undefined);
}
}
}
);
}
function handleDecode() {
@@ -735,7 +726,9 @@ export default function Send() {
</Button>
</VStack>
</Show>
<FeedbackLink />
<div class="flex justify-center">
<GiftLink />
</div>
</VStack>
</DefaultMain>
<NavBar activeTab="send" />

View File

@@ -1,9 +1,8 @@
import { Match, Show, Suspense, Switch } from "solid-js";
import { Show, Suspense } from "solid-js";
import { A } from "solid-start";
import scan from "~/assets/icons/scan.svg";
import settings from "~/assets/icons/settings.svg";
import pixelLogo from "~/assets/mutiny-pixel-logo.png";
import plusLogo from "~/assets/mutiny-plus-logo.png";
import {
BalanceBox,
BetaWarningModal,
@@ -13,6 +12,7 @@ import {
DefaultMain,
LoadingIndicator,
LoadingShimmer,
Logo,
NavBar,
OnboardWarning,
PendingNwc,
@@ -34,24 +34,7 @@ export default function App() {
<LoadingIndicator />
<header class="mb-2 mt-4 flex w-full items-center justify-between">
<div class="flex items-center gap-2">
<Switch>
<Match when={state.mutiny_plus}>
<img
id="mutiny-logo"
src={plusLogo}
class="h-[25px] w-[86px]"
alt="Mutiny Plus logo"
/>
</Match>
<Match when={true}>
<img
id="mutiny-logo"
src={pixelLogo}
class="h-[25px] w-[75px]"
alt="Mutiny logo"
/>
</Match>
</Switch>
<Logo />
<Show
when={
!state.wallet_loading &&
@@ -68,12 +51,24 @@ export default function App() {
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<A
class="rounded-lg p-2 hover:bg-white/5 active:bg-m-blue md:hidden"
href="/scanner"
>
<img src={scan} alt="Scan" class="h-6 w-6" />
</A>
<A
class="rounded-lg p-2 hover:bg-white/5 active:bg-m-blue md:hidden"
href="/settings"
>
<img src={settings} alt="Settings" class="h-6 w-6" />
<img
src={settings}
alt="Settings"
class="h-6 w-6"
/>
</A>
</div>
</header>
<Show when={!state.wallet_loading}>
<OnboardWarning />

View File

@@ -139,18 +139,27 @@ function NwcDetails(props: {
)}
remaining={Number(props.profile.budget_remaining || 0)}
/>
<KeyValue key={"Budget"}>
<KeyValue key={i18n.t("settings.connections.budget")}>
<AmountSats amountSats={props.profile.budget_amount} />
</KeyValue>
<KeyValue key={"Resets every"}>
{/* No interval for gifts */}
<Show when={props.profile.budget_period}>
<KeyValue key={i18n.t("settings.connections.resets_every")}>
{props.profile.budget_period}
</KeyValue>
</Show>
</Show>
<Button layout="small" intent="green" onClick={props.onEdit}>
{i18n.t("settings.connections.edit_budget")}
</Button>
<Show
when={
props.profile.tag !== "Gift" &&
props.profile.tag !== "Subscription"
}
>
<Button layout="small" onClick={openInNostrClient}>
{i18n.t("settings.connections.open_in_nostr_client")}
</Button>
@@ -158,6 +167,7 @@ function NwcDetails(props: {
<Button layout="small" onClick={openInPrimal}>
{i18n.t("settings.connections.open_in_primal")}
</Button>
</Show>
<Button layout="small" onClick={confirmDelete}>
{i18n.t("settings.connections.delete_connection")}
@@ -292,7 +302,7 @@ function Nwc() {
<SettingsCard
title={i18n.t("settings.connections.manage_connections")}
>
<For each={nwcProfiles()}>
<For each={nwcProfiles()?.filter(p => p.tag !== "Gift")}>
{(profile) => (
<Collapser
title={profile.name}

View File

@@ -0,0 +1,310 @@
import {
createForm,
getValue,
required,
reset,
setValue,
SubmitHandler
} from "@modular-forms/solid";
import { NwcProfile } from "@mutinywallet/mutiny-wasm";
import {
createEffect,
createResource,
createSignal,
For,
Match,
Show,
Suspense,
Switch
} from "solid-js";
import {
AmountCard,
BackPop,
Button,
Collapser,
ConfirmDialog,
DefaultMain,
InfoBox,
IntegratedQr,
LargeHeader,
LoadingSpinner,
MutinyWalletGuard,
NavBar,
NiceP,
SafeArea,
SettingsCard,
TextField,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import { eify } from "~/utils";
import { createDeepSignal } from "~/utils/deepSignal";
type CreateGiftForm = {
name: string;
amount: string;
};
export function SingleGift(props: { profile: NwcProfile; onDelete?: () => void }) {
const i18n = useI18n();
const [state, _actions] = useMegaStore();
const baseUrl = window.location.origin;
const sharableUrl = () => baseUrl.concat(props.profile.url_suffix || "");
const amount = () => props.profile.budget_amount?.toString() || "0";
const [confirmOpen, setConfirmOpen] = createSignal(false);
const handleConfirmDelete = async () => {
try {
await state.mutiny_wallet?.delete_nwc_profile(props.profile.index);
setConfirmOpen(false);
props.onDelete && props.onDelete();
} catch (e) {
console.error(e);
}
};
return (
<VStack>
<IntegratedQr
amountSats={amount()}
value={sharableUrl()}
kind="gift"
/>
<Button intent="red" onClick={() => setConfirmOpen(true)}>
{i18n.t("settings.gift.send_delete_button")}
</Button>
<ConfirmDialog
loading={false}
open={confirmOpen()}
onConfirm={handleConfirmDelete}
onCancel={() => setConfirmOpen(false)}
>
{i18n.t("settings.gift.send_delete_confirm")}
</ConfirmDialog>
</VStack>
);
}
function ExistingGifts() {
const [state, _actions] = useMegaStore();
const [giftNWCProfiles, { refetch }] = createResource(async () => {
try {
const profiles: NwcProfile[] =
await state.mutiny_wallet?.get_nwc_profiles();
const filteredForGifts = profiles.filter((p) => p.tag === "Gift");
return filteredForGifts;
} catch (e) {
console.error(e);
}
});
return (
<Show when={giftNWCProfiles() && giftNWCProfiles()!.length > 0}>
<SettingsCard title={"Existing Gifts"}>
<For each={giftNWCProfiles()}>
{(profile) => (
<Collapser title={profile.name} activityLight={"on"}>
<SingleGift profile={profile} onDelete={refetch} />
</Collapser>
)}
</For>
</SettingsCard>
</Show>
);
}
export default function GiftPage() {
const i18n = useI18n();
const [state, _actions] = useMegaStore();
const [_error, setError] = createSignal<Error>();
const [giftResult, setGiftResult] = createSignal<NwcProfile>();
const [giftForm, { Form, Field }] = createForm<CreateGiftForm>({
initialValues: {
name: "",
amount: "50000"
}
});
function resetGifting() {
reset(giftForm);
setGiftResult(undefined);
}
const handleSubmit: SubmitHandler<CreateGiftForm> = async (
f: CreateGiftForm
) => {
const nwc_name = f.name.trim();
const amount = Number(f.amount);
try {
const profile = await state.mutiny_wallet?.create_single_use_nwc(
nwc_name,
BigInt(amount)
);
setGiftResult(profile);
} catch (e) {
console.error(e);
setError(eify(e));
}
};
async function fetchProfile(gift?: NwcProfile) {
if (!gift) return;
try {
const fresh = await state.mutiny_wallet?.get_nwc_profile(
gift.index
);
return fresh;
} catch (e) {
console.error(e);
// If the gift is not found it means it's been deleted because it was redeemed
return undefined;
}
}
const [freshProfile, { refetch }] = createResource(
() => giftResult(),
fetchProfile,
{
storage: createDeepSignal
}
);
createEffect(() => {
// Should re-run after every sync
if (!state.is_syncing) {
refetch();
}
});
const lessThan50k = () => {
return Number(getValue(giftForm, "amount")) < 50000;
};
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackPop />
<Show when={giftResult()}>
<VStack>
<Switch>
<Match when={!freshProfile()}>
<LargeHeader>
{i18n.t(
"settings.gift.send_header_claimed"
)}
</LargeHeader>
<NiceP>
{i18n.t("settings.gift.send_claimed")}
</NiceP>
</Match>
<Match when={true}>
<LargeHeader>
{i18n.t(
"settings.gift.send_sharable_header"
)}
</LargeHeader>
<NiceP>
{i18n.t(
"settings.gift.send_instructions"
)}
</NiceP>
<SingleGift
profile={freshProfile()!}
onDelete={resetGifting}
/>
</Match>
</Switch>
<Button intent="green" onClick={resetGifting}>
{i18n.t("settings.gift.send_another")}
</Button>
</VStack>
</Show>
<Show when={!giftResult()}>
<LargeHeader>
{i18n.t("settings.gift.send_header")}
</LargeHeader>
<Form onSubmit={handleSubmit}>
<VStack>
<NiceP>
{i18n.t("settings.gift.send_explainer")}
</NiceP>
<Field
name="name"
validate={[
required(
i18n.t(
"settings.gift.send_name_required"
)
)
]}
>
{(field, props) => (
<TextField
{...props}
value={field.value}
error={field.error}
label={i18n.t(
"settings.gift.send_name_label"
)}
placeholder="Satoshi Nakamoto"
/>
)}
</Field>
<Field name="amount">
{(field) => (
<>
<AmountCard
amountSats={field.value || "0"}
isAmountEditable
setAmountSats={(newAmount) =>
setValue(
giftForm,
"amount",
newAmount.toString()
)
}
/>
</>
)}
</Field>
<Show when={lessThan50k()}>
<InfoBox accent="green">
{i18n.t(
"settings.gift.send_small_warning"
)}
</InfoBox>
</Show>
<Button
intent="blue"
type="submit"
loading={giftForm.submitting}
>
{i18n.t("settings.gift.send_cta")}
</Button>
</VStack>
</Form>
<Suspense fallback={<LoadingSpinner />}>
<ExistingGifts />
</Suspense>
</Show>
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -191,7 +191,7 @@ function PlusCTA() {
<strong class="text-white">
{i18n.t("settings.plus.title")}
</strong>
?{i18n.t("settings.plus.click_confirm")}
? {i18n.t("settings.plus.click_confirm")}
</p>
</ConfirmDialog>
</Show>

View File

@@ -132,6 +132,13 @@ export default function Settings() {
href: "/settings/connections",
text: i18n.t("settings.connections.title")
},
{
href: "/settings/gift",
disabled: !state.mutiny_plus,
text: i18n.t("settings.gift.title"),
caption: !state.mutiny_plus ? "Upgrade to Mutiny+ to enabled gifting" : undefined
},
{
href: "/settings/lnurlauth",
text: i18n.t("settings.lnurl_auth.title")

View File

@@ -10,7 +10,7 @@ import {
useContext
} from "solid-js";
import { createStore } from "solid-js/store";
import { useSearchParams } from "solid-start";
import { useNavigate, useSearchParams } from "solid-start";
import { Currency, FIAT_OPTIONS } from "~/components/ChooseCurrency";
import { checkBrowserCompatibility } from "~/logic/browserCompatibility";
@@ -21,7 +21,7 @@ import {
MutinyWalletSettingStrings,
setupMutinyWallet
} from "~/logic/mutinyWalletSetup";
import { ParsedParams } from "~/logic/waila";
import { ParsedParams, toParsedParams } from "~/logic/waila";
import { eify, MutinyTagItem, subscriptionValid } from "~/utils";
const MegaStoreContext = createContext<MegaStore>();
@@ -71,11 +71,17 @@ export type MegaStore = [
setPreferredInvoiceType(
type: "unified" | "lightning" | "onchain"
): void;
handleIncomingString(
str: string,
onError: (e: Error) => void,
onSuccess: (value: ParsedParams) => void
): void;
}
];
export const Provider: ParentComponent = (props) => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [state, setState] = createStore({
mutiny_wallet: undefined as MutinyWallet | undefined,
@@ -314,6 +320,41 @@ export const Provider: ParentComponent = (props) => {
},
setPreferredInvoiceType(type: "unified" | "lightning" | "onchain") {
setState({ preferredInvoiceType: type });
},
handleIncomingString(
str: string,
onError: (e: Error) => void,
onSuccess: (value: ParsedParams) => void
): void {
try {
const url = new URL(str);
if (url && url.pathname.startsWith("/gift")) {
navigate(url.pathname + url.search);
return;
}
} catch (e) {
// If it's not a URL, we'll just continue with normal parsing
}
const network = state.mutiny_wallet?.get_network() || "signet";
const result = toParsedParams(str || "", network);
if (!result.ok) {
if (onError) {
onError(result.error);
}
return;
} else {
if (
result.value?.address ||
result.value?.invoice ||
result.value?.node_pubkey ||
result.value?.lnurl
) {
if (onSuccess) {
onSuccess(result.value);
}
}
}
}
};