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)); props.setAmountSats(BigInt(props.initialAmountSats));
setIsOpen(false); setIsOpen(false);
setLocalSats(props.initialAmountSats); setLocalSats(props.initialAmountSats);
@@ -576,6 +577,7 @@ export const AmountEditable: ParentComponent<{
) )
); );
props.exitRoute && navigate(props.exitRoute); props.exitRoute && navigate(props.exitRoute);
return false;
} }
// What we're all here for in the first place: returning a value // 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) satsToFiat(state.price, Number(localSats()) || 0, state.fiat)
); );
setIsOpen(false); setIsOpen(false);
return false;
} }
function handleSatsInput(e: InputEvent) { function handleSatsInput(e: InputEvent) {
@@ -706,6 +709,7 @@ export const AmountEditable: ParentComponent<{
<div class="flex w-full justify-end"> <div class="flex w-full justify-end">
<button <button
onClick={handleClose} onClick={handleClose}
type="button"
class="h-8 w-8 rounded-lg hover:bg-white/10 active:bg-m-blue" class="h-8 w-8 rounded-lg hover:bg-white/10 active:bg-m-blue"
> >
<img src={close} alt="Close" /> <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 = export const DIALOG_POSITIONER =
"fixed inset-0 z-50 flex items-center justify-center"; "fixed inset-0 z-50 flex items-center justify-center";
export const DIALOG_CONTENT = 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: { function LightningHeader(props: {
info: MutinyInvoice; 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 { ReceiveFlavor } from "~/routes/Receive";
import { useCopy } from "~/utils"; import { useCopy } from "~/utils";
function KindIndicator(props: { kind: ReceiveFlavor }) { function KindIndicator(props: { kind: ReceiveFlavor | "gift" }) {
const i18n = useI18n(); const i18n = useI18n();
return ( return (
<div class="flex flex-col items-end text-black"> <div class="flex flex-col items-end text-black">
@@ -29,6 +29,13 @@ function KindIndicator(props: { kind: ReceiveFlavor }) {
<img src={boltBlack} alt="bolt" /> <img src={boltBlack} alt="bolt" />
</Match> </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"}> <Match when={props.kind === "unified"}>
<h3 class="font-semibold"> <h3 class="font-semibold">
{i18n.t("receive.integrated_qr.unified")} {i18n.t("receive.integrated_qr.unified")}
@@ -62,7 +69,7 @@ async function share(receiveString: string) {
export function IntegratedQr(props: { export function IntegratedQr(props: {
value: string; value: string;
amountSats: string; amountSats: string;
kind: ReceiveFlavor; kind: ReceiveFlavor | "gift";
}) { }) {
const i18n = useI18n(); const i18n = useI18n();
const [copy, copied] = useCopy({ copiedTimeout: 1000 }); 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> <TinyText>
{i18n.t("settings.connections.careful")} {i18n.t("settings.connections.careful")}
</TinyText> </TinyText>
<KeyValue key={"Budget"}> <KeyValue key={i18n.t("settings.connections.budget")}>
<Field name="budget_amount"> <Field name="budget_amount">
{(field, _fieldProps) => ( {(field, _fieldProps) => (
<div class="flex flex-col items-end gap-2"> <div class="flex flex-col items-end gap-2">
@@ -129,7 +129,9 @@ export function NWCBudgetEditor(props: {
)} )}
</Field> </Field>
</KeyValue> </KeyValue>
<KeyValue key={"Resets every"}> <KeyValue
key={i18n.t("settings.connections.resets_every")}
>
<Field name="interval"> <Field name="interval">
{(field, fieldProps) => ( {(field, fieldProps) => (
<select <select

View File

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

View File

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

View File

@@ -14,7 +14,10 @@ export function BackPop() {
const state = location.state as StateWithPrevious; 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 ( return (
<BackButton <BackButton

View File

@@ -89,7 +89,8 @@ export default {
integrated_qr: { integrated_qr: {
onchain: "On-chain", onchain: "On-chain",
lightning: "Lightning", lightning: "Lightning",
unified: "Unified" unified: "Unified",
gift: "Lightning Gift"
}, },
remember_choice: "Remember my choice next time" remember_choice: "Remember my choice next time"
}, },
@@ -269,6 +270,7 @@ export default {
error_budget_zero: "Budget must be greater than zero", error_budget_zero: "Budget must be greater than zero",
add_connection: "Add Connection", add_connection: "Add Connection",
manage_connections: "Manage Connections", manage_connections: "Manage Connections",
manage_gifts: "Manage Gifts",
delete_connection: "Delete", delete_connection: "Delete",
new_connection: "New Connection", new_connection: "New Connection",
edit_connection: "Edit Connection", edit_connection: "Edit Connection",
@@ -292,7 +294,9 @@ export default {
"Be careful where you share this connection! Requests within budget will paid automatically.", "Be careful where you share this connection! Requests within budget will paid automatically.",
spent: "Spent", spent: "Spent",
remaining: "Remaining", 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: { emergency_kit: {
title: "Emergency Kit", title: "Emergency Kit",
@@ -448,6 +452,41 @@ export default {
npub_label: "Nostr npub", npub_label: "Nostr npub",
npub_required: "Npub can't be blank", npub_required: "Npub can't be blank",
sync: "Sync" 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: { 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 { Button, Scanner as Reader, showToast } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { toParsedParams } from "~/logic/waila";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
export default function Scanner() { export default function Scanner() {
const i18n = useI18n(); const i18n = useI18n();
const [state, actions] = useMegaStore(); const [_state, actions] = useMegaStore();
const [scanResult, setScanResult] = createSignal<string>(); const [scanResult, setScanResult] = createSignal<string>();
const navigate = useNavigate(); 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 // When we have a nice result we can head over to the send screen
createEffect(() => { createEffect(() => {
if (scanResult()) { if (scanResult()) {
const network = state.mutiny_wallet?.get_network() || "signet"; actions.handleIncomingString(
const result = toParsedParams(scanResult() || "", network); scanResult()!,
if (!result.ok) { (error) => {
showToast(result.error); showToast(error);
return; },
} else { (result) => {
if ( actions.setScanResult(result);
result.value?.address ||
result.value?.invoice ||
result.value?.node_pubkey ||
result.value?.lnurl
) {
actions.setScanResult(result.value);
navigate("/send"); navigate("/send");
} }
} );
} }
}); });

View File

@@ -26,6 +26,7 @@ import {
DefaultMain, DefaultMain,
ExternalLink, ExternalLink,
Fee, Fee,
GiftLink,
HStack, HStack,
InfoBox, InfoBox,
LargeHeader, LargeHeader,
@@ -44,12 +45,10 @@ import {
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { Network } from "~/logic/mutinyWalletSetup"; import { Network } from "~/logic/mutinyWalletSetup";
import { ParsedParams, toParsedParams } from "~/logic/waila"; import { ParsedParams } from "~/logic/waila";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { eify, mempoolTxUrl, MutinyTagItem } from "~/utils"; import { eify, mempoolTxUrl, MutinyTagItem } from "~/utils";
import { FeedbackLink } from "./Feedback";
export type SendSource = "lightning" | "onchain"; export type SendSource = "lightning" | "onchain";
// const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl" // const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl"
@@ -379,25 +378,17 @@ export default function Send() {
}); });
function parsePaste(text: string) { function parsePaste(text: string) {
if (text) { actions.handleIncomingString(
const network = state.mutiny_wallet?.get_network() || "signet"; text,
const result = toParsedParams(text || "", network); (error) => {
if (!result.ok) { showToast(error);
showToast(result.error); },
return; (result) => {
} else { setDestination(result);
if ( // Important! we need to clear the scan result once we've used it
result.value?.address || actions.setScanResult(undefined);
result.value?.invoice ||
result.value?.node_pubkey ||
result.value?.lnurl
) {
setDestination(result.value);
// Important! we need to clear the scan result once we've used it
actions.setScanResult(undefined);
}
} }
} );
} }
function handleDecode() { function handleDecode() {
@@ -735,7 +726,9 @@ export default function Send() {
</Button> </Button>
</VStack> </VStack>
</Show> </Show>
<FeedbackLink /> <div class="flex justify-center">
<GiftLink />
</div>
</VStack> </VStack>
</DefaultMain> </DefaultMain>
<NavBar activeTab="send" /> <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 { A } from "solid-start";
import scan from "~/assets/icons/scan.svg";
import settings from "~/assets/icons/settings.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 { import {
BalanceBox, BalanceBox,
BetaWarningModal, BetaWarningModal,
@@ -13,6 +12,7 @@ import {
DefaultMain, DefaultMain,
LoadingIndicator, LoadingIndicator,
LoadingShimmer, LoadingShimmer,
Logo,
NavBar, NavBar,
OnboardWarning, OnboardWarning,
PendingNwc, PendingNwc,
@@ -34,24 +34,7 @@ export default function App() {
<LoadingIndicator /> <LoadingIndicator />
<header class="mb-2 mt-4 flex w-full items-center justify-between"> <header class="mb-2 mt-4 flex w-full items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Switch> <Logo />
<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>
<Show <Show
when={ when={
!state.wallet_loading && !state.wallet_loading &&
@@ -68,12 +51,24 @@ export default function App() {
</div> </div>
</Show> </Show>
</div> </div>
<A <div class="flex items-center gap-2">
class="rounded-lg p-2 hover:bg-white/5 active:bg-m-blue md:hidden" <A
href="/settings" class="rounded-lg p-2 hover:bg-white/5 active:bg-m-blue md:hidden"
> href="/scanner"
<img src={settings} alt="Settings" class="h-6 w-6" /> >
</A> <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"
/>
</A>
</div>
</header> </header>
<Show when={!state.wallet_loading}> <Show when={!state.wallet_loading}>
<OnboardWarning /> <OnboardWarning />

View File

@@ -139,25 +139,35 @@ function NwcDetails(props: {
)} )}
remaining={Number(props.profile.budget_remaining || 0)} remaining={Number(props.profile.budget_remaining || 0)}
/> />
<KeyValue key={"Budget"}> <KeyValue key={i18n.t("settings.connections.budget")}>
<AmountSats amountSats={props.profile.budget_amount} /> <AmountSats amountSats={props.profile.budget_amount} />
</KeyValue> </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} {props.profile.budget_period}
</KeyValue> </KeyValue>
</Show>
</Show> </Show>
<Button layout="small" intent="green" onClick={props.onEdit}> <Button layout="small" intent="green" onClick={props.onEdit}>
{i18n.t("settings.connections.edit_budget")} {i18n.t("settings.connections.edit_budget")}
</Button> </Button>
<Button layout="small" onClick={openInNostrClient}> <Show
{i18n.t("settings.connections.open_in_nostr_client")} when={
</Button> props.profile.tag !== "Gift" &&
props.profile.tag !== "Subscription"
}
>
<Button layout="small" onClick={openInNostrClient}>
{i18n.t("settings.connections.open_in_nostr_client")}
</Button>
<Button layout="small" onClick={openInPrimal}> <Button layout="small" onClick={openInPrimal}>
{i18n.t("settings.connections.open_in_primal")} {i18n.t("settings.connections.open_in_primal")}
</Button> </Button>
</Show>
<Button layout="small" onClick={confirmDelete}> <Button layout="small" onClick={confirmDelete}>
{i18n.t("settings.connections.delete_connection")} {i18n.t("settings.connections.delete_connection")}
@@ -292,7 +302,7 @@ function Nwc() {
<SettingsCard <SettingsCard
title={i18n.t("settings.connections.manage_connections")} title={i18n.t("settings.connections.manage_connections")}
> >
<For each={nwcProfiles()}> <For each={nwcProfiles()?.filter(p => p.tag !== "Gift")}>
{(profile) => ( {(profile) => (
<Collapser <Collapser
title={profile.name} 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"> <strong class="text-white">
{i18n.t("settings.plus.title")} {i18n.t("settings.plus.title")}
</strong> </strong>
?{i18n.t("settings.plus.click_confirm")} ? {i18n.t("settings.plus.click_confirm")}
</p> </p>
</ConfirmDialog> </ConfirmDialog>
</Show> </Show>

View File

@@ -132,6 +132,13 @@ export default function Settings() {
href: "/settings/connections", href: "/settings/connections",
text: i18n.t("settings.connections.title") 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", href: "/settings/lnurlauth",
text: i18n.t("settings.lnurl_auth.title") text: i18n.t("settings.lnurl_auth.title")

View File

@@ -10,7 +10,7 @@ import {
useContext useContext
} from "solid-js"; } from "solid-js";
import { createStore } from "solid-js/store"; 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 { Currency, FIAT_OPTIONS } from "~/components/ChooseCurrency";
import { checkBrowserCompatibility } from "~/logic/browserCompatibility"; import { checkBrowserCompatibility } from "~/logic/browserCompatibility";
@@ -21,7 +21,7 @@ import {
MutinyWalletSettingStrings, MutinyWalletSettingStrings,
setupMutinyWallet setupMutinyWallet
} from "~/logic/mutinyWalletSetup"; } from "~/logic/mutinyWalletSetup";
import { ParsedParams } from "~/logic/waila"; import { ParsedParams, toParsedParams } from "~/logic/waila";
import { eify, MutinyTagItem, subscriptionValid } from "~/utils"; import { eify, MutinyTagItem, subscriptionValid } from "~/utils";
const MegaStoreContext = createContext<MegaStore>(); const MegaStoreContext = createContext<MegaStore>();
@@ -71,11 +71,17 @@ export type MegaStore = [
setPreferredInvoiceType( setPreferredInvoiceType(
type: "unified" | "lightning" | "onchain" type: "unified" | "lightning" | "onchain"
): void; ): void;
handleIncomingString(
str: string,
onError: (e: Error) => void,
onSuccess: (value: ParsedParams) => void
): void;
} }
]; ];
export const Provider: ParentComponent = (props) => { export const Provider: ParentComponent = (props) => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [state, setState] = createStore({ const [state, setState] = createStore({
mutiny_wallet: undefined as MutinyWallet | undefined, mutiny_wallet: undefined as MutinyWallet | undefined,
@@ -314,6 +320,41 @@ export const Provider: ParentComponent = (props) => {
}, },
setPreferredInvoiceType(type: "unified" | "lightning" | "onchain") { setPreferredInvoiceType(type: "unified" | "lightning" | "onchain") {
setState({ preferredInvoiceType: type }); 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);
}
}
}
} }
}; };