add transfer funds screen

This commit is contained in:
Paul Miller
2024-05-07 10:22:19 -05:00
parent 336ec4b2cf
commit 87d8964b86
9 changed files with 454 additions and 107 deletions

View File

@@ -265,7 +265,8 @@
"back_home": "back home"
},
"start_a_chat": "Start a chat?",
"start_a_chat_are_you_sure": "This user isn't in your contact list."
"start_a_chat_are_you_sure": "This user isn't in your contact list.",
"federation_message": "Federation Message"
},
"scanner": {
"paste": "Paste Something",
@@ -566,7 +567,8 @@
"descriptionpart2": "Each one is run by a group of different individuals or companies. Discover one that you or your friends might trust below.",
"join_me": "Join me",
"recommend": "Recommend federation",
"recommended_by_you": "Recommended by you"
"recommended_by_you": "Recommended by you",
"transfer_funds": "Transfer funds"
},
"gift": {
"give_sats_link": "Give sats as a gift",
@@ -787,5 +789,11 @@
"nowish": "Nowish",
"seconds_future": "Seconds from now",
"seconds_past": "Just now"
},
"transfer": {
"completed": "Transfer Completed",
"sats_moved": "+{{amount}} sats have been moved to {{federation_name}}",
"confirm": "Confirm Transfer",
"title": "Transfer funds"
}
}

View File

@@ -8,6 +8,7 @@ import {
createSignal,
For,
Match,
onMount,
Show,
Suspense,
Switch
@@ -364,7 +365,7 @@ function NewContactModal(props: { profile: PseudoContact; close: () => void }) {
}
export function CombinedActivity() {
const [state, _actions, sw] = useMegaStore();
const [state, actions, sw] = useMegaStore();
const i18n = useI18n();
const [detailsOpen, setDetailsOpen] = createSignal(false);
@@ -407,6 +408,17 @@ export function CombinedActivity() {
const [newContact, setNewContact] = createSignal<PseudoContact>();
const [
showFederationExpirationWarning,
setShowFederationExpirationWarning
] = createSignal(false);
onMount(() => {
if (state.expiration_warning) {
setShowFederationExpirationWarning(true);
}
});
return (
<>
<Show when={detailsId() && detailsKind()}>
@@ -424,6 +436,32 @@ export function CombinedActivity() {
/>
</Show>
<Suspense fallback={<LoadingShimmer />}>
<Show when={state.expiration_warning}>
<SimpleDialog
title={i18n.t("activity.federation_message")}
open={showFederationExpirationWarning()}
setOpen={(open: boolean) => {
if (!open) {
setShowFederationExpirationWarning(false);
actions.clearExpirationWarning();
}
}}
>
<NiceP>
{state.expiration_warning?.expiresMessage}
</NiceP>
<ButtonCard
onClick={() => navigate("/settings/federations")}
>
<div class="flex items-center gap-2">
<Users class="inline-block text-m-red" />
<NiceP>
{i18n.t("profile.manage_federation")}
</NiceP>
</div>
</ButtonCard>
</SimpleDialog>
</Show>
<Show when={!state.has_backed_up}>
<ButtonCard
red

View File

@@ -19,7 +19,7 @@ import {
} from "~/utils";
export type MethodChoice = {
method: "lightning" | "onchain";
method: "lightning" | "onchain" | "fedimint";
maxAmountSats?: bigint;
};
@@ -29,6 +29,8 @@ function methodToIcon(method: MethodChoice["method"]) {
return "lightning";
} else if (method === "onchain") {
return "chain";
} else if (method === "fedimint") {
return "community";
}
}

View File

@@ -33,7 +33,8 @@ import {
Search,
Send,
Swap,
SwapLightning
SwapLightning,
Transfer
} from "~/routes";
import {
Admin,
@@ -179,6 +180,7 @@ export function Router() {
<Route path="/send" component={Send} />
<Route path="/swap" component={Swap} />
<Route path="/swaplightning" component={SwapLightning} />
<Route path="/transfer" component={Transfer} />
<Route path="/search" component={Search} />
<Route path="/settings">
<Route path="/" component={Settings} />

216
src/routes/Transfer.tsx Normal file
View File

@@ -0,0 +1,216 @@
import { FedimintSweepResult } from "@mutinywallet/mutiny-wasm";
import { createAsync, useNavigate, useSearchParams } from "@solidjs/router";
import { ArrowDown, Users } from "lucide-solid";
import { createMemo, createSignal, Match, Suspense, Switch } from "solid-js";
import {
AmountEditable,
AmountFiat,
AmountSats,
BackLink,
Button,
DefaultMain,
Failure,
Fee,
LargeHeader,
MegaCheck,
MutinyWalletGuard,
SharpButton,
SuccessModal,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import { eify, vibrateSuccess } from "~/utils";
type TransferResultDetails = {
result?: FedimintSweepResult;
failure_reason?: string;
};
export function Transfer() {
const [state, _actions, sw] = useMegaStore();
const i18n = useI18n();
const navigate = useNavigate();
const [amountSats, setAmountSats] = createSignal(0n);
const [loading, setLoading] = createSignal(false);
const [params] = useSearchParams();
const canTransfer = createMemo(() => {
return true;
});
const [transferResult, setTransferResult] =
createSignal<TransferResultDetails>();
const fromFed = () => {
return state.federations?.find((f) => f.federation_id === params.from);
};
const toFed = () => {
return state.federations?.find((f) => f.federation_id !== params.from);
};
const federationBalances = createAsync(async () => {
try {
const balances = await sw.get_federation_balances();
return balances?.balances || [];
} catch (e) {
console.error(e);
return [];
}
});
const calculateMaxFederation = createAsync(async () => {
return federationBalances()?.find(
(f) => f.identity_federation_id === fromFed()?.federation_id
)?.balance;
});
const toBalance = createAsync(async () => {
return federationBalances()?.find(
(f) => f.identity_federation_id === toFed()?.federation_id
)?.balance;
});
const isMax = createMemo(() => {
return amountSats() === calculateMaxFederation();
});
async function handleTransfer() {
try {
setLoading(true);
if (!fromFed()) throw new Error("No from federation");
if (!toFed()) throw new Error("No to federation");
if (isMax()) {
const result = await sw.sweep_federation_balance(
undefined,
fromFed()?.federation_id,
toFed()?.federation_id
);
setTransferResult({ result: result });
} else {
const result = await sw.sweep_federation_balance(
amountSats(),
fromFed()?.federation_id,
toFed()?.federation_id
);
setTransferResult({ result: result });
}
await vibrateSuccess();
} catch (e) {
const error = eify(e);
setTransferResult({ failure_reason: error.message });
console.error(e);
} finally {
setLoading(false);
}
}
// const fromFederatationId = params.from;
return (
<MutinyWalletGuard>
<DefaultMain>
<SuccessModal
confirmText={
transferResult()?.result
? i18n.t("common.nice")
: i18n.t("common.home")
}
open={!!transferResult()}
setOpen={(open: boolean) => {
if (!open) setTransferResult(undefined);
}}
onConfirm={() => {
setTransferResult(undefined);
navigate("/");
}}
>
<Switch>
<Match when={transferResult()?.failure_reason}>
<Failure
reason={transferResult()!.failure_reason!}
/>
</Match>
<Match when={transferResult()?.result}>
<MegaCheck />
<div class="flex flex-col justify-center">
<h1 class="mb-2 mt-4 w-full justify-center text-center text-2xl font-semibold md:text-3xl">
{i18n.t("transfer.completed")}
</h1>
<p class="text-center text-xl">
{i18n.t("transfer.sats_moved", {
amount: Number(
transferResult()?.result?.amount
).toLocaleString(),
federation_name:
toFed()?.federation_name
})}
</p>
<div class="text-center text-sm text-white/70">
<Suspense>
<AmountFiat
amountSats={Number(
transferResult()?.result?.amount
)}
/>
</Suspense>
</div>
</div>
<hr class="w-16 bg-m-grey-400" />
<Fee
amountSats={Number(
transferResult()?.result?.fees
)}
/>
</Match>
</Switch>
</SuccessModal>
<BackLink href="/settings/federations" />
<LargeHeader>{i18n.t("transfer.title")}</LargeHeader>
<div class="flex flex-1 flex-col justify-between gap-2">
<div class="flex-1" />
<div class="flex flex-col items-center">
<AmountEditable
initialAmountSats={amountSats()}
setAmountSats={setAmountSats}
/>
<SharpButton disabled onClick={() => {}}>
<Users class="w-[18px]" />
{fromFed()?.federation_name}
<AmountSats
amountSats={calculateMaxFederation()}
denominationSize="sm"
/>
</SharpButton>
<ArrowDown class="h-4 w-4" />
<SharpButton disabled onClick={() => {}}>
<Users class="w-[18px]" />
{toFed()?.federation_name}
<AmountSats
amountSats={toBalance()}
denominationSize="sm"
/>
</SharpButton>
</div>
<div class="flex-1" />
<VStack>
<Button
disabled={!canTransfer()}
intent="blue"
onClick={handleTransfer}
loading={loading()}
>
{i18n.t("transfer.confirm")}
</Button>
</VStack>
</div>
</DefaultMain>
</MutinyWalletGuard>
);
}

View File

@@ -12,3 +12,4 @@ export * from "./Request";
export * from "./EditProfile";
export * from "./Swap";
export * from "./SwapLightning";
export * from "./Transfer";

View File

@@ -7,8 +7,9 @@ import {
} from "@modular-forms/solid";
import { FederationBalance, TagItem } from "@mutinywallet/mutiny-wasm";
import { A, useNavigate, useSearchParams } from "@solidjs/router";
import { BadgeCheck, LogOut, Scan, Trash } from "lucide-solid";
import { ArrowLeftRight, BadgeCheck, LogOut, Scan, Trash } from "lucide-solid";
import {
createMemo,
createResource,
createSignal,
For,
@@ -57,6 +58,7 @@ export type MutinyFederationIdentity = {
welcome_message: string;
federation_expiry_timestamp: number;
invite_code: string;
meta_external_url?: string;
};
export type Metadata = {
@@ -240,99 +242,12 @@ export function AddFederationForm(props: {
<Match when={federations.latest}>
<For each={federations()}>
{(fed) => (
<FancyCard>
<VStack>
<div class="flex items-center gap-2 md:gap-4">
<LabelCircle
name={fed.metadata?.name}
image_url={
fed.metadata?.picture
}
contact={false}
label={false}
/>
<div>
<header class={`font-semibold`}>
{fed.metadata?.name}
</header>
<Show
when={fed.metadata?.about}
>
<p>{fed.metadata?.about}</p>
</Show>
</div>
</div>
<Show when={!props.setup}>
<KeyValue
key={i18n.t(
"settings.manage_federations.federation_id"
)}
>
<MiniStringShower
text={fed.id}
/>
</KeyValue>
</Show>
<Show when={fed.created_at}>
<KeyValue
key={i18n.t(
"settings.manage_federations.created_at"
)}
>
<time>
{timeAgo(fed.created_at)}
</time>
</KeyValue>
</Show>
<Show
when={
fed.recommendations.length > 0
}
>
<KeyValue
key={i18n.t(
"settings.manage_federations.recommended_by"
)}
>
<div class="flex items-center gap-2 overflow-scroll md:gap-4">
<For
each={
fed.recommendations
}
>
{(contact) => (
<LabelCircle
name={
contact.name
}
image_url={
contact.image_url
}
contact={true}
label={false}
/>
)}
</For>
</div>
</KeyValue>
</Show>
<Show when={!props.browseOnly}>
<Button
intent="blue"
onClick={() =>
onSelect(fed.invite_codes)
}
loading={fed.invite_codes.includes(
loadingFederation()
)}
>
{i18n.t(
"settings.manage_federations.add"
)}
</Button>
</Show>
</VStack>
</FancyCard>
<FederationFormItem
fed={fed}
onSelect={onSelect}
loadingFederation={loadingFederation()}
setup={!!props.setup}
/>
)}
</For>
</Match>
@@ -342,6 +257,94 @@ export function AddFederationForm(props: {
);
}
function FederationFormItem(props: {
fed: DiscoveredFederation;
onSelect: (invite_codes: string[]) => void;
loadingFederation: string;
setup: boolean;
}) {
const [state, _actions, _sw] = useMegaStore();
const i18n = useI18n();
const alreadyAdded = createMemo(() => {
const matches = state.federations?.find((f) =>
props.fed.invite_codes.includes(f.invite_code)
);
return matches !== undefined;
});
return (
<FancyCard>
<VStack>
<div class="flex items-center gap-2 md:gap-4">
<LabelCircle
name={props.fed.metadata?.name}
image_url={props.fed.metadata?.picture}
contact={false}
label={false}
/>
<div>
<header class={`font-semibold`}>
{props.fed.metadata?.name}
</header>
<Show when={props.fed.metadata?.about}>
<p>{props.fed.metadata?.about}</p>
</Show>
</div>
</div>
<Show when={!props.setup}>
<KeyValue
key={i18n.t(
"settings.manage_federations.federation_id"
)}
>
<MiniStringShower text={props.fed.id} />
</KeyValue>
</Show>
<Show when={props.fed.created_at}>
<KeyValue
key={i18n.t("settings.manage_federations.created_at")}
>
<time>{timeAgo(props.fed.created_at)}</time>
</KeyValue>
</Show>
<Show when={props.fed.recommendations.length > 0}>
<KeyValue
key={i18n.t(
"settings.manage_federations.recommended_by"
)}
>
<div class="flex items-center gap-2 overflow-scroll md:gap-4">
<For each={props.fed.recommendations}>
{(contact) => (
<LabelCircle
name={contact.name}
image_url={contact.image_url}
contact={true}
label={false}
/>
)}
</For>
</div>
</KeyValue>
</Show>
<Show
when={!alreadyAdded() && !(state.federations?.length === 2)}
>
<Button
intent="blue"
onClick={() => props.onSelect(props.fed.invite_codes)}
loading={props.fed.invite_codes.includes(
props.loadingFederation
)}
>
{i18n.t("settings.manage_federations.add")}
</Button>
</Show>
</VStack>
</FancyCard>
);
}
function RecommendButton(props: { fed: MutinyFederationIdentity }) {
const [_state, _actions, sw] = useMegaStore();
const i18n = useI18n();
@@ -425,7 +428,8 @@ function FederationListItem(props: {
balance?: bigint;
}) {
const i18n = useI18n();
const [_state, actions, sw] = useMegaStore();
const [state, actions, sw] = useMegaStore();
const navigate = useNavigate();
async function removeFederation() {
setConfirmLoading(true);
@@ -442,6 +446,10 @@ function FederationListItem(props: {
setConfirmOpen(true);
}
async function transferFunds() {
navigate("/transfer?from=" + props.fed.federation_id);
}
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmLoading, setConfirmLoading] = createSignal(false);
@@ -449,6 +457,7 @@ function FederationListItem(props: {
<>
<FancyCard>
<VStack>
{/* <pre>{JSON.stringify(props.fed, null, 2)}</pre> */}
<Show when={props.fed.federation_name}>
<header class={`font-semibold`}>
{props.fed.federation_name}
@@ -490,6 +499,19 @@ function FederationListItem(props: {
inviteCode={props.fed.invite_code}
/>
</KeyValue>
<Show
when={
state.federations?.length &&
state.federations.length === 2
}
>
<SubtleButton onClick={transferFunds}>
<ArrowLeftRight class="h-4 w-4" />
{i18n.t(
"settings.manage_federations.transfer_funds"
)}
</SubtleButton>
</Show>
<Suspense>
<RecommendButton fed={props.fed} />
</Suspense>
@@ -606,7 +628,7 @@ export function ManageFederations() {
</VStack>
<Suspense>
<Show when={state.federations?.length}>
<AddFederationForm refetch={refetch} browseOnly />
<AddFederationForm refetch={refetch} />
</Show>
</Suspense>
</DefaultMain>

View File

@@ -87,7 +87,10 @@ export const makeMegaStoreContext = () => {
testflightPromptDismissed:
localStorage.getItem("testflightPromptDismissed") === "true",
federations: undefined as MutinyFederationIdentity[] | undefined,
balanceView: localStorage.getItem("balanceView") || "sats"
balanceView: localStorage.getItem("balanceView") || "sats",
expiration_warning: undefined as
| { expiresTimestamp: number; expiresMessage: string }
| undefined
});
const actions = {
@@ -226,15 +229,59 @@ export const makeMegaStoreContext = () => {
const balance = await sw.get_balance();
// Get federations
const federations =
(await sw.list_federations()) as MutinyFederationIdentity[];
const federations = await sw.list_federations();
let expiration_warning:
| { expiresTimestamp: number; expiresMessage: string }
| undefined = undefined;
try {
if (federations.length) {
const activeFederation = federations[0];
const metadataUrl = activeFederation.meta_external_url;
console.log("federation metadata url", metadataUrl);
if (metadataUrl) {
const response = await fetch(metadataUrl);
if (response.ok) {
const metadata = await response.json();
console.log(
"all federation metadata",
metadata
);
const specificFederation =
metadata[activeFederation.federation_id];
console.log(
"specific federation metadata",
specificFederation
);
const expiresTimestamp =
specificFederation.popup_end_timestamp;
console.log(
"federation expires",
expiresTimestamp
);
const expiresMessage =
specificFederation.popup_countdown_message;
expiration_warning = {
expiresTimestamp,
expiresMessage
};
}
}
}
} catch (e) {
console.error("Error getting federation metadata", e);
}
console.log("expiration_warning", expiration_warning);
setState({
wallet_loading: false,
load_stage: "done",
balance,
federations,
network: network as Network
network: network as Network,
expiration_warning
});
// Timestamp our initialization for double init defense
@@ -506,6 +553,10 @@ export const makeMegaStoreContext = () => {
channel.postMessage({ type: "EXISTING_TAB" });
}
};
},
// Only show the expiration warning once per session
clearExpirationWarning() {
setState({ expiration_warning: undefined });
}
};

View File

@@ -217,6 +217,7 @@ export async function get_balance(): Promise<MutinyBalance> {
*/
export async function list_federations(): Promise<MutinyFederationIdentity[]> {
const federations = await wallet!.list_federations();
console.log("list_federations", federations);
return federations as MutinyFederationIdentity[];
}
@@ -1548,9 +1549,15 @@ export async function estimate_sweep_channel_open_fee(
* @returns {Promise<FedimintSweepResult>}
*/
export async function sweep_federation_balance(
amount?: bigint
amount?: bigint,
from_federation_id?: string,
to_federation_id?: string
): Promise<FedimintSweepResult> {
const result = await wallet!.sweep_federation_balance(amount);
const result = await wallet!.sweep_federation_balance(
amount,
from_federation_id,
to_federation_id
);
return { ...result.value } as FedimintSweepResult;
}