This commit is contained in:
Paul Miller
2024-03-20 16:33:41 -05:00
parent 33b8190a2d
commit 52d89a2617
37 changed files with 2044 additions and 2489 deletions

View File

@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { loadHome, visitSettings } from "./utils"; import { loadHome } from "./utils";
const SIGNET_INVITE_CODE = const SIGNET_INVITE_CODE =
"fed11qgqzc2nhwden5te0vejkg6tdd9h8gepwvejkg6tdd9h8garhduhx6at5d9h8jmn9wshxxmmd9uqqzgxg6s3evnr6m9zdxr6hxkdkukexpcs3mn7mj3g5pc5dfh63l4tj6g9zk4er"; "fed11qgqzc2nhwden5te0vejkg6tdd9h8gepwvejkg6tdd9h8garhduhx6at5d9h8jmn9wshxxmmd9uqqzgxg6s3evnr6m9zdxr6hxkdkukexpcs3mn7mj3g5pc5dfh63l4tj6g9zk4er";
@@ -11,10 +11,9 @@ test.beforeEach(async ({ page }) => {
test("fedmint join, receive, send", async ({ page }) => { test("fedmint join, receive, send", async ({ page }) => {
await loadHome(page); await loadHome(page);
await visitSettings(page);
// Click "Manage Federations" link // Click "Join a federation" cta
await page.click("text=Manage Federations"); await page.click("text=Join a federation");
// Fill the input with the federation code // Fill the input with the federation code
await page.fill("input[name='federation_code']", SIGNET_INVITE_CODE); await page.fill("input[name='federation_code']", SIGNET_INVITE_CODE);
@@ -29,7 +28,6 @@ test("fedmint join, receive, send", async ({ page }) => {
// Navigate back home // Navigate back home
await page.goBack(); await page.goBack();
await page.goBack();
// Click the top left button (it's the profile button), a child of header // Click the top left button (it's the profile button), a child of header
// TODO: better ARIA stuff // TODO: better ARIA stuff

View File

@@ -26,8 +26,7 @@ const settingsRoutes = [
"/plus", "/plus",
"/restore", "/restore",
"/servers", "/servers",
"/syncnostrcontacts", "/nostrkeys"
"/federations"
]; ];
const settingsRoutesPrefixed = settingsRoutes.map((route) => { const settingsRoutesPrefixed = settingsRoutes.map((route) => {
@@ -96,31 +95,8 @@ test("visit each route", async ({ page }) => {
await checkRoute(page, "/settings/servers", "Servers", checklist); await checkRoute(page, "/settings/servers", "Servers", checklist);
await page.goBack(); await page.goBack();
// Connections
await checkRoute(
page,
"/settings/connections",
"Wallet Connections",
checklist
);
await page.goBack();
// Sync Nostr Contacts // Sync Nostr Contacts
await checkRoute( await checkRoute(page, "/settings/nostrkeys", "Nostr Keys", checklist);
page,
"/settings/syncnostrcontacts",
"Sync Nostr Contacts",
checklist
);
await page.goBack();
// Manage Federations
await checkRoute(
page,
"/settings/federations",
"Manage Federations",
checklist
);
await page.goBack(); await page.goBack();
// Emergency Kit // Emergency Kit

View File

@@ -52,19 +52,19 @@
"@capacitor/share": "^5.0.6", "@capacitor/share": "^5.0.6",
"@capacitor/status-bar": "^5.0.6", "@capacitor/status-bar": "^5.0.6",
"@capacitor/toast": "^5.0.6", "@capacitor/toast": "^5.0.6",
"@kobalte/core": "^0.12.1", "@kobalte/core": "^0.12.6",
"@kobalte/tailwindcss": "^0.9.0", "@kobalte/tailwindcss": "^0.9.0",
"@mutinywallet/mutiny-wasm": "0.6.0-rc3", "@mutinywallet/mutiny-wasm": "0.6.0-rc5",
"@modular-forms/solid": "^0.20.0", "@modular-forms/solid": "^0.20.0",
"@solid-primitives/upload": "^0.0.111", "@solid-primitives/upload": "^0.0.117",
"@solidjs/meta": "^0.29.3", "@solidjs/meta": "^0.29.3",
"@solidjs/router": "^0.10.9", "@solidjs/router": "^0.13.1",
"capacitor-secure-storage-plugin": "^0.9.0", "capacitor-secure-storage-plugin": "^0.9.0",
"i18next": "^23.10.1", "i18next": "^23.10.1",
"i18next-browser-languagedetector": "^7.1.0", "i18next-browser-languagedetector": "^7.1.0",
"lucide-solid": "^0.330.0", "lucide-solid": "^0.363.0",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"solid-js": "^1.8.12", "solid-js": "^1.8.16",
"solid-qr-code": "^0.0.8", "solid-qr-code": "^0.0.8",
"solid-transition-group": "^0.2.3" "solid-transition-group": "^0.2.3"
}, },

3278
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,15 @@
"profile": "Profile", "profile": "Profile",
"nostr_identity": "Nostr Identity", "nostr_identity": "Nostr Identity",
"add_lightning_address": "Add Lightning Address", "add_lightning_address": "Add Lightning Address",
"edit_profile": "Edit Profile" "edit_profile": "Edit Profile",
"join_federation": "Join a federation",
"manage_federation": "Manage Federations",
"federated_custody": "Federated Custody",
"self_custody": "Self Custody",
"social": "Social",
"edit": {
"nym": "Nym"
}
}, },
"chat": { "chat": {
"prompt": "This is a new conversation. Try asking for money!", "prompt": "This is a new conversation. Try asking for money!",
@@ -74,8 +82,7 @@
"email_error": "That doesn't look like a lightning address", "email_error": "That doesn't look like a lightning address",
"npub_error": "That doesn't look like a nostr npub", "npub_error": "That doesn't look like a nostr npub",
"error_ln_address_missing": "New contacts need a lightning address", "error_ln_address_missing": "New contacts need a lightning address",
"npub": "Nostr Npub", "npub": "Nostr Npub"
"link_to_nostr_sync": "Import Nostr Contacts"
}, },
"redeem": { "redeem": {
"redeem_bitcoin": "Redeem Bitcoin", "redeem_bitcoin": "Redeem Bitcoin",
@@ -533,7 +540,7 @@
"remove": "Remove", "remove": "Remove",
"expires": "Expires", "expires": "Expires",
"federation_id": "Federation ID", "federation_id": "Federation ID",
"description": "Mutiny has experimental support for the Fedimint protocol. You'll need a federation invite code to use this feature. These funds are currently not backed up remotely. Store funds in a federation at your own risk!", "description": "Mutiny has experimental support for the Fedimint protocol. You'll need a federation invite code to use this feature. Store funds in a federation at your own risk!",
"learn_more": "Learn more about Fedimint." "learn_more": "Learn more about Fedimint."
}, },
"gift": { "gift": {
@@ -567,6 +574,15 @@
"send_delete_confirm": "Are you sure you want to delete this gift? Is this your rugpull moment?", "send_delete_confirm": "Are you sure you want to delete this gift? Is this your rugpull moment?",
"send_tip": "Your copy of Mutiny Wallet needs to be open for the gift to be redeemed.", "send_tip": "Your copy of Mutiny Wallet needs to be open for the gift to be redeemed.",
"need_plus": "Upgrade to Mutiny+ to enable gifting. Gifting allows you to create a Mutiny gift URL that can be claimed by anyone with a web browser." "need_plus": "Upgrade to Mutiny+ to enable gifting. Gifting allows you to create a Mutiny gift URL that can be claimed by anyone with a web browser."
},
"appearance": "Appearance",
"social": "Social",
"nostr_keys": {
"title": "Nostr Keys",
"caption": "Not your keys not your notes",
"description": "You can use the same nostr profile across multiple apps.",
"warning": "Be careful where you share your private nostr key!",
"learn_more": "Learn more about nostr"
} }
}, },
"swap": { "swap": {
@@ -700,4 +716,4 @@
"authenticated": "Authenticated!" "authenticated": "Authenticated!"
} }
} }
} }

View File

@@ -49,8 +49,7 @@
"email_error": "Eso no parece una dirección lightning", "email_error": "Eso no parece una dirección lightning",
"npub_error": "Eso no parece un npub de nostr", "npub_error": "Eso no parece un npub de nostr",
"error_ln_address_missing": "Los contactos nuevos necesitan una dirección lightning", "error_ln_address_missing": "Los contactos nuevos necesitan una dirección lightning",
"npub": "Npub Nostr", "npub": "Npub Nostr"
"link_to_nostr_sync": "Importar Contactos de Nostr"
}, },
"receive": { "receive": {
"receive_bitcoin": "Recibir Bitcoin", "receive_bitcoin": "Recibir Bitcoin",
@@ -660,5 +659,8 @@
"error": "Eso no funcionó por alguna razón.", "error": "Eso no funcionó por alguna razón.",
"authenticated": "¡Autenticado!" "authenticated": "¡Autenticado!"
} }
},
"profile": {
"manage_federation": "Manejar Federaciones"
} }
} }

View File

@@ -149,10 +149,12 @@ function OnchainHeader(props: { info: OnChainTx; kind?: HackActivityType }) {
{props.kind === "ChannelOpen" {props.kind === "ChannelOpen"
? i18n.t("activity.transaction_details.channel_open") ? i18n.t("activity.transaction_details.channel_open")
: props.kind === "ChannelClose" : props.kind === "ChannelClose"
? i18n.t("activity.transaction_details.channel_close") ? i18n.t("activity.transaction_details.channel_close")
: isSend() : isSend()
? i18n.t("activity.transaction_details.onchain_send") ? i18n.t("activity.transaction_details.onchain_send")
: i18n.t("activity.transaction_details.onchain_receive")} : i18n.t(
"activity.transaction_details.onchain_receive"
)}
<Switch> <Switch>
<Match <Match
when={ when={
@@ -191,15 +193,29 @@ function OnchainHeader(props: { info: OnChainTx; kind?: HackActivityType }) {
); );
} }
export function MiniStringShower(props: { text: string }) { export function MiniStringShower(props: { text: string; hide?: boolean }) {
const [copy, copied] = useCopy({ copiedTimeout: 1000 }); const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return ( return (
<div class="grid w-full grid-cols-[minmax(0,_1fr)_auto] gap-1"> <div class="grid w-full grid-cols-[minmax(0,_1fr)_auto] gap-1">
<TruncateMiddle text={props.text} /> <Switch>
<Match when={props.hide}>
<input
type="password"
value={props.text}
class="flex bg-transparent font-mono"
readonly
disabled
/>
</Match>
<Match when={true}>
<TruncateMiddle text={props.text} />
</Match>
</Switch>
<button <button
class="w-[1.5rem] p-1" class="w-[1.5rem] p-1"
classList={{ "bg-m-green rounded": copied() }} classList={{ "bg-m-red rounded": copied() }}
onClick={() => copy(props.text)} onClick={() => copy(props.text)}
> >
<Copy class="h-4 w-4" /> <Copy class="h-4 w-4" />
@@ -302,8 +318,8 @@ function OnchainDetails(props: {
await (state.mutiny_wallet?.list_channels() as Promise< await (state.mutiny_wallet?.list_channels() as Promise<
MutinyChannel[] MutinyChannel[]
>); >);
const channel = channels.find( const channel = channels.find((channel) =>
(channel) => channel.outpoint?.startsWith(props.info.txid) channel.outpoint?.startsWith(props.info.txid)
); );
return channel; return channel;
} catch (e) { } catch (e) {
@@ -492,9 +508,8 @@ export function ActivityDetailsModal(props: {
try { try {
if (kind() === "Lightning") { if (kind() === "Lightning") {
console.debug("reading invoice: ", id()); console.debug("reading invoice: ", id());
const invoice = await state.mutiny_wallet?.get_invoice_by_hash( const invoice =
id() await state.mutiny_wallet?.get_invoice_by_hash(id());
);
return invoice; return invoice;
} else if (kind() === "ChannelClose") { } else if (kind() === "ChannelClose") {
console.debug("reading channel close: ", id()); console.debug("reading channel close: ", id());

View File

@@ -1,13 +1,16 @@
import { A } from "@solidjs/router"; import { A, useNavigate } from "@solidjs/router";
import { Shuffle } from "lucide-solid"; import { Shuffle, Users } from "lucide-solid";
import { Match, Show, Switch } from "solid-js"; import { Match, Show, Switch } from "solid-js";
import { import {
AmountFiat, AmountFiat,
AmountSats, AmountSats,
ButtonCard,
FancyCard, FancyCard,
Indicator, Indicator,
InfoBox, InfoBox,
MediumHeader,
NiceP,
VStack VStack
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
@@ -45,6 +48,7 @@ const STYLE =
export function BalanceBox(props: { loading?: boolean; small?: boolean }) { export function BalanceBox(props: { loading?: boolean; small?: boolean }) {
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
const navigate = useNavigate();
const i18n = useI18n(); const i18n = useI18n();
const totalOnchain = () => const totalOnchain = () =>
@@ -57,115 +61,151 @@ export function BalanceBox(props: { loading?: boolean; small?: boolean }) {
return ( return (
<VStack> <VStack>
<FancyCard title="Lightning"> <Switch>
<Show when={!props.loading} fallback={<LoadingShimmer />}> <Match when={state.federations && state.federations.length}>
<Switch> <div>
<Match when={state.safe_mode}> <MediumHeader>Fedimint</MediumHeader>
<div class="flex flex-col gap-1"> <FancyCard>
<InfoBox accent="red"> <Show
{i18n.t("common.error_safe_mode")} when={!props.loading}
</InfoBox> fallback={<LoadingShimmer />}
</div> >
</Match> <div class="flex justify-between">
<Match when={true}> <div class="flex flex-col gap-1">
<div class="flex flex-col gap-1"> <div class="text-2xl">
<div class="text-2xl"> <AmountSats
<AmountSats amountSats={
amountSats={ state.balance?.federation ||
state.balance?.lightning || 0 0n
}
icon="community"
denominationSize="lg"
isFederation
/>
</div>
<div class="text-lg text-white/70">
<AmountFiat
amountSats={
state.balance?.federation ||
0n
}
denominationSize="sm"
/>
</div>
</div>
<Show
when={
state.balance?.federation || 0n > 0n
} }
icon="lightning" >
denominationSize="lg" <div class="self-end justify-self-end">
/> <A
href="/swaplightning"
class={STYLE}
>
<Shuffle class="h-6 w-6" />
</A>
</div>
</Show>
</div> </div>
<div class="text-lg text-white/70"> </Show>
<AmountFiat </FancyCard>
amountSats={ </div>
state.balance?.lightning || 0 <ButtonCard
} onClick={() => navigate("/settings/federations")}
denominationSize="sm" >
/> <div class="flex items-center gap-2">
<Users class="inline-block text-m-red" />
<NiceP>{i18n.t("profile.manage_federation")}</NiceP>
</div>
</ButtonCard>
</Match>
<Match when={true}>
<ButtonCard
onClick={() => navigate("/settings/federations")}
>
<div class="flex items-center gap-2">
<Users class="inline-block text-m-red" />
<NiceP>{i18n.t("profile.join_federation")}</NiceP>
</div>
</ButtonCard>
</Match>
</Switch>
<div>
<MediumHeader>{i18n.t("profile.self_custody")}</MediumHeader>
<FancyCard>
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<Switch>
<Match when={state.safe_mode}>
<div class="flex flex-col gap-1">
<InfoBox accent="red">
{i18n.t("common.error_safe_mode")}
</InfoBox>
</div> </div>
</div> </Match>
</Match> <Match when={true}>
</Switch> <div class="flex flex-col gap-1">
</Show> <div class="text-2xl">
</FancyCard> <AmountSats
<Show when={state.federations && state.federations.length}> amountSats={
<FancyCard title="Fedimint"> state.balance?.lightning || 0
}
icon="lightning"
denominationSize="lg"
/>
</div>
<div class="text-lg text-white/70">
<AmountFiat
amountSats={
state.balance?.lightning || 0
}
denominationSize="sm"
/>
</div>
</div>
</Match>
</Switch>
</Show>
<hr class="my-2 border-m-grey-750" />
<Show when={!props.loading} fallback={<LoadingShimmer />}> <Show when={!props.loading} fallback={<LoadingShimmer />}>
<div class="flex justify-between"> <div class="flex justify-between">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="text-2xl"> <div class="text-2xl">
<AmountSats <AmountSats
amountSats={ amountSats={totalOnchain()}
state.balance?.federation || 0n icon="chain"
}
icon="community"
denominationSize="lg" denominationSize="lg"
isFederation
/> />
</div> </div>
<div class="text-lg text-white/70"> <div class="text-lg text-white/70">
<AmountFiat <AmountFiat
amountSats={ amountSats={totalOnchain()}
state.balance?.federation || 0n
}
denominationSize="sm" denominationSize="sm"
/> />
</div> </div>
</div> </div>
<div class="flex flex-col items-end justify-between gap-1">
<Show when={state.balance?.federation || 0n > 0n}> <Show when={state.balance?.unconfirmed != 0n}>
<div class="self-end justify-self-end"> <Indicator>
<A href="/swaplightning" class={STYLE}> {i18n.t("common.pending")}
<Shuffle class="h-6 w-6" /> </Indicator>
</A> </Show>
</div> <Show when={state.balance?.unconfirmed === 0n}>
</Show> <div />
</Show>
<Show when={usableOnchain() > 0n}>
<div class="self-end justify-self-end">
<A href="/swap" class={STYLE}>
<Shuffle class="h-6 w-6" />
</A>
</div>
</Show>
</div>
</div> </div>
</Show> </Show>
</FancyCard> </FancyCard>
</Show> </div>
<FancyCard title="On-chain">
{/* <hr class="my-2 border-m-grey-750" /> */}
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<div class="flex justify-between">
<div class="flex flex-col gap-1">
<div class="text-2xl">
<AmountSats
amountSats={totalOnchain()}
icon="chain"
denominationSize="lg"
/>
</div>
<div class="text-lg text-white/70">
<AmountFiat
amountSats={totalOnchain()}
denominationSize="sm"
/>
</div>
</div>
<div class="flex flex-col items-end justify-between gap-1">
<Show when={state.balance?.unconfirmed != 0n}>
<Indicator>
{i18n.t("common.pending")}
</Indicator>
</Show>
<Show when={state.balance?.unconfirmed === 0n}>
<div />
</Show>
<Show when={usableOnchain() > 0n}>
<div class="self-end justify-self-end">
<A href="/swap" class={STYLE}>
<Shuffle class="h-6 w-6" />
</A>
</div>
</Show>
</div>
</div>
</Show>
</FancyCard>
</VStack> </VStack>
); );
} }

View File

@@ -8,7 +8,10 @@ export function ContactButton(props: {
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
<button class="flex items-center gap-2" onClick={() => props.onClick()}> <button
class="flex items-center gap-2 overflow-clip"
onClick={() => props.onClick()}
>
<LabelCircle <LabelCircle
name={props.contact.name} name={props.contact.name}
image_url={props.contact.primal_image_url} image_url={props.contact.primal_image_url}

View File

@@ -1,5 +1,4 @@
import { SubmitHandler } from "@modular-forms/solid"; import { SubmitHandler } from "@modular-forms/solid";
import { A } from "@solidjs/router";
import { createSignal, Match, Switch } from "solid-js"; import { createSignal, Match, Switch } from "solid-js";
import { import {
@@ -61,15 +60,6 @@ export function ContactEditor(props: {
cta={i18n.t("contacts.create_contact")} cta={i18n.t("contacts.create_contact")}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
/> />
<A
href="/settings/syncnostrcontacts"
class="self-center font-semibold text-m-red no-underline active:text-m-red/80"
state={{
previous: location.pathname
}}
>
{i18n.t("contacts.link_to_nostr_sync")}
</A>
</SimpleDialog> </SimpleDialog>
</> </>
); );

View File

@@ -1,12 +1,16 @@
import { createForm, email } from "@modular-forms/solid";
import { createFileUploader } from "@solid-primitives/upload"; import { createFileUploader } from "@solid-primitives/upload";
import { Pencil } from "lucide-solid";
import { createSignal, Match, Switch } from "solid-js"; import { createSignal, Match, Switch } from "solid-js";
import { Button, SimpleInput } from "~/components"; import { Button, TextField, VStack } from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { blobToBase64 } from "~/utils"; import { blobToBase64 } from "~/utils";
export type EditableProfile = { export type EditableProfile = {
nym?: string; nym?: string;
lightningAddress?: string;
imageUrl?: string; imageUrl?: string;
}; };
@@ -17,7 +21,6 @@ export function EditProfileForm(props: {
cta: string; cta: string;
}) { }) {
const [state] = useMegaStore(); const [state] = useMegaStore();
const [nym, setNym] = createSignal(props.initialProfile?.nym || "");
const [uploading, setUploading] = createSignal(false); const [uploading, setUploading] = createSignal(false);
const { files, selectFiles } = createFileUploader({ const { files, selectFiles } = createFileUploader({
@@ -26,14 +29,20 @@ export function EditProfileForm(props: {
}); });
async function uploadFile() { async function uploadFile() {
selectFiles(async (files) => { console.log("uploadFile");
await selectFiles(async (files) => {
if (files.length) { if (files.length) {
return; return;
} }
}); });
} }
async function onSave() { const i18n = useI18n();
const [profileForm, { Form, Field }] = createForm<EditableProfile>({
initialValues: { ...props.initialProfile }
});
async function handleSubmit(profile: EditableProfile) {
try { try {
let imageUrl; let imageUrl;
if (files() && files().length) { if (files() && files().length) {
@@ -46,7 +55,8 @@ export function EditProfileForm(props: {
setUploading(false); setUploading(false);
} }
await props.onSave({ await props.onSave({
nym: nym(), nym: profile.nym,
lightningAddress: profile.lightningAddress,
imageUrl: imageUrl ? imageUrl : props.initialProfile?.imageUrl imageUrl: imageUrl ? imageUrl : props.initialProfile?.imageUrl
}); });
} catch (e) { } catch (e) {
@@ -56,33 +66,67 @@ export function EditProfileForm(props: {
return ( return (
<> <>
<button <VStack>
class="shiny-button flex h-[8rem] w-[8rem] flex-none items-center justify-center self-center overflow-clip rounded-full bg-m-grey-800 text-5xl uppercase" <button class="relative self-center" onClick={uploadFile}>
onClick={uploadFile} <div class="shiny-button flex h-[8rem] w-[8rem] flex-none items-center justify-center self-center overflow-clip rounded-full bg-m-grey-800 text-5xl uppercase">
> <Switch>
<Switch> <Match when={files() && files().length}>
<Match when={files() && files().length}> <img src={files()[0].source} />
<img src={files()[0].source} /> </Match>
</Match> <Match when={props.initialProfile?.imageUrl}>
<Match when={props.initialProfile?.imageUrl}> <img src={props.initialProfile?.imageUrl} />
<img src={props.initialProfile?.imageUrl} /> </Match>
</Match> </Switch>
<Match when={true}>+</Match> </div>
</Switch> <div class="absolute top-0 flex h-[8rem] w-[8rem] items-center justify-center bg-m-grey-975/25">
</button> <Pencil />
<SimpleInput </div>
value={nym()} </button>
onInput={(e) => setNym(e.currentTarget.value)} <VStack>
placeholder="Your name or nym" <Form
/> onSubmit={handleSubmit}
class="mx-auto flex w-full flex-1 flex-col justify-around gap-4"
>
<Field name="nym">
{(field, props) => (
<TextField
{...props}
placeholder={i18n.t("contacts.placeholder")}
value={field.value}
error={field.error}
label={i18n.t("profile.edit.nym")}
/>
)}
</Field>
<Field
name="lightningAddress"
validate={[email(i18n.t("contacts.email_error"))]}
>
{(field, props) => (
<TextField
{...props}
placeholder="example@example.com"
value={field.value}
error={field.error}
label={i18n.t("contacts.ln_address")}
/>
)}
</Field>
<Button
layout="full"
type="submit"
loading={
props.saving ||
uploading() ||
profileForm.submitting
}
>
{props.cta}
</Button>
</Form>
</VStack>
</VStack>
<div class="flex-1" /> <div class="flex-1" />
<Button
layout="full"
onClick={onSave}
loading={props.saving || uploading()}
>
{props.cta}
</Button>
</> </>
); );
} }

View File

@@ -6,8 +6,11 @@ import { useMegaStore } from "~/state/megaStore";
export function HomeBalance() { export function HomeBalance() {
const [state, actions] = useMegaStore(); const [state, actions] = useMegaStore();
const lightningPlusFedi = () => const combinedBalance = () =>
(state.balance?.federation || 0n) + (state.balance?.lightning || 0n); (state.balance?.federation || 0n) +
(state.balance?.lightning || 0n) +
(state.balance?.confirmed || 0n) +
(state.balance?.unconfirmed || 0n);
// TODO: do some sort of status indicator // TODO: do some sort of status indicator
// const fullyReady = () => state.load_stage === "done" && state.price !== 0; // const fullyReady = () => state.load_stage === "done" && state.price !== 0;
@@ -31,14 +34,14 @@ export function HomeBalance() {
<Switch> <Switch>
<Match when={state.balanceView === "sats"}> <Match when={state.balanceView === "sats"}>
<AmountSats <AmountSats
amountSats={lightningPlusFedi()} amountSats={combinedBalance()}
icon="lightning" icon="lightning"
denominationSize="lg" denominationSize="lg"
/> />
</Match> </Match>
<Match when={state.balanceView === "fiat"}> <Match when={state.balanceView === "fiat"}>
<AmountFiat <AmountFiat
amountSats={lightningPlusFedi()} amountSats={combinedBalance()}
denominationSize="lg" denominationSize="lg"
/> />
</Match> </Match>

View File

@@ -0,0 +1,46 @@
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { SecureStoragePlugin } from "capacitor-secure-storage-plugin";
import { createSignal, Show } from "solid-js";
import { Button, InfoBox, SimpleInput } from "~/components";
export function ImportNsecForm() {
const [nsec, setNsec] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal<string | undefined>();
async function saveNsec() {
setSaving(true);
setError(undefined);
const trimmedNsec = nsec().trim();
try {
const npub = await MutinyWallet.nsec_to_npub(trimmedNsec);
if (!npub) {
throw new Error("Invalid nsec");
}
await SecureStoragePlugin.set({ key: "nsec", value: trimmedNsec });
// TODO: right now we need a reload to set the nsec
window.location.href = "/";
} catch (e) {
console.error(e);
setError("Invalid nsec");
}
setSaving(false);
}
return (
<>
<SimpleInput
value={nsec()}
onInput={(e) => setNsec(e.currentTarget.value)}
placeholder={`Nostr private key (starts with "nsec")`}
/>
<Button layout="full" onClick={saveNsec} loading={saving()}>
Import
</Button>
<Show when={error()}>
<InfoBox accent="red">{error()}</InfoBox>
</Show>
</>
);
}

View File

@@ -55,8 +55,8 @@ export function LabelCircle(props: {
props.contact && props.name && props.name.length props.contact && props.name && props.name.length
? props.name[0] ? props.name[0]
: props.label : props.label
? "≡" ? "≡"
: "?"; : "?";
const bg = () => (props.name && props.contact ? gradient() : ""); const bg = () => (props.name && props.contact ? gradient() : "");
const [errored, setErrored] = createSignal(false); const [errored, setErrored] = createSignal(false);
@@ -64,7 +64,11 @@ export function LabelCircle(props: {
return ( return (
<Circle <Circle
background={props.image_url && !errored() ? "none" : bg()} background={props.image_url && !errored() ? "none" : bg()}
onClick={() => props.onClick && props.onClick()} onClick={
props.onClick
? () => props.onClick && props.onClick()
: undefined
}
size={props.size} size={props.size}
> >
<Switch> <Switch>

View File

@@ -113,8 +113,8 @@ export function NWCEditor(props: {
const mode: "createnwa" | "createnwc" | "editnwc" = nwa() const mode: "createnwa" | "createnwc" | "editnwc" = nwa()
? "createnwa" ? "createnwa"
: props.initialProfileIndex : props.initialProfileIndex
? "editnwc" ? "editnwc"
: "createnwc"; : "createnwc";
return mode; return mode;
}); });

View File

@@ -137,8 +137,8 @@ export function NostrActivity() {
zap.kind === "anonymous" zap.kind === "anonymous"
? i18n.t("activity.anonymous") ? i18n.t("activity.anonymous")
: zap.kind === "private" : zap.kind === "private"
? i18n.t("activity.private") ? i18n.t("activity.private")
: nameFromHexpub(zap.from_hexpub) : nameFromHexpub(zap.from_hexpub)
} }
primaryOnClick={() => { primaryOnClick={() => {
newContactFromHexpub(zap.from_hexpub); newContactFromHexpub(zap.from_hexpub);

View File

@@ -34,7 +34,11 @@ function ShareButton(props: { receiveString: string; whiteBg?: boolean }) {
); );
} }
export function TruncateMiddle(props: { text: string; whiteBg?: boolean }) { export function TruncateMiddle(props: {
text: string;
whiteBg?: boolean;
hidden?: boolean;
}) {
return ( return (
<div <div
class="flex font-mono" class="flex font-mono"

View File

@@ -56,3 +56,4 @@ export * from "./ContactButton";
export * from "./GenericItem"; export * from "./GenericItem";
export * from "./HomeBalance"; export * from "./HomeBalance";
export * from "./EditProfileForm"; export * from "./EditProfileForm";
export * from "./ImportNsecForm";

View File

@@ -33,8 +33,8 @@ export function BackPop(props: { default: string; title?: string }) {
props.title !== undefined props.title !== undefined
? props.title ? props.title
: backPath() === "/" : backPath() === "/"
? i18n.t("common.home") ? i18n.t("common.home")
: i18n.t("common.back") : i18n.t("common.back")
} }
onClick={() => navigate(backPath())} onClick={() => navigate(backPath())}
showOnDesktop showOnDesktop

View File

@@ -228,6 +228,16 @@ export const LargeHeader: ParentComponent<{
); );
}; };
export const MediumHeader: ParentComponent = (props) => {
return (
<header class="mt-2">
<h2 class="text-xl font-semibold text-m-grey-350">
{props.children}
</h2>
</header>
);
};
export const VStack: ParentComponent<{ export const VStack: ParentComponent<{
biggap?: boolean; biggap?: boolean;
smallgap?: boolean; smallgap?: boolean;

View File

@@ -36,8 +36,8 @@ export function toParsedParams(
const network = !params.network const network = !params.network
? ourNetwork ? ourNetwork
: params.network === "testnet" && ourNetwork === "signet" : params.network === "testnet" && ourNetwork === "signet"
? "signet" ? "signet"
: params.network; : params.network;
if (network !== ourNetwork) { if (network !== ourNetwork) {
return { return {

View File

@@ -16,11 +16,11 @@ body {
/* After load we need to remove the bg so the qr scanner can show through */ /* After load we need to remove the bg so the qr scanner can show through */
@apply text-white; @apply text-white;
@apply !bg-transparent; @apply !bg-transparent;
@apply mx-auto flex w-full max-w-[600px] flex-1 flex-col safe-top safe-left safe-right safe-bottom min-h-device h-device; @apply flex w-full flex-1 flex-col safe-top safe-left safe-right safe-bottom min-h-device h-device;
} }
#root { #root {
@apply flex flex-1 flex-col; @apply mx-auto flex w-full max-w-[600px] flex-1 flex-col;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {

View File

@@ -49,11 +49,11 @@ import {
Gift, Gift,
Language, Language,
ManageFederations, ManageFederations,
NostrKeys,
Plus, Plus,
Restore, Restore,
Servers, Servers,
Settings, Settings
SyncNostrContacts
} from "~/routes/settings"; } from "~/routes/settings";
import { Provider as MegaStoreProvider, useMegaStore } from "~/state/megaStore"; import { Provider as MegaStoreProvider, useMegaStore } from "~/state/megaStore";
@@ -187,10 +187,7 @@ export function Router() {
<Route path="/plus" component={Plus} /> <Route path="/plus" component={Plus} />
<Route path="/restore" component={Restore} /> <Route path="/restore" component={Restore} />
<Route path="/servers" component={Servers} /> <Route path="/servers" component={Servers} />
<Route <Route path="/nostrkeys" component={NostrKeys} />
path="/syncnostrcontacts"
component={SyncNostrContacts}
/>
<Route path="/federations" component={ManageFederations} /> <Route path="/federations" component={ManageFederations} />
</Route> </Route>
<Route path="/*all" component={NotFound} /> <Route path="/*all" component={NotFound} />

View File

@@ -1,6 +1,13 @@
import { TagItem } from "@mutinywallet/mutiny-wasm"; import { TagItem } from "@mutinywallet/mutiny-wasm";
import { createAsync, useNavigate, useParams } from "@solidjs/router"; import { createAsync, useNavigate, useParams } from "@solidjs/router";
import { ArrowDownLeft, ArrowUpRight, MessagesSquare, Zap } from "lucide-solid"; import {
ArrowDownLeft,
ArrowUpRight,
Check,
MessagesSquare,
X,
Zap
} from "lucide-solid";
import { import {
createEffect, createEffect,
createResource, createResource,
@@ -310,6 +317,36 @@ function FixedChatHeader(props: {
navigate("/search"); navigate("/search");
} }
const [updatingFollowStatus, setUpdatingFollowStatus] = createSignal(false);
async function followContact() {
setUpdatingFollowStatus(true);
try {
if (!props.contact.npub) throw new Error("No npub");
await state.mutiny_wallet?.follow_npub(props.contact.npub);
props.refetch();
} catch (e) {
console.error(e);
showToast(eify(e));
}
setUpdatingFollowStatus(false);
}
async function unfollowContact() {
setUpdatingFollowStatus(true);
try {
if (!props.contact.npub) throw new Error("No npub");
await state.mutiny_wallet?.unfollow_npub(props.contact.npub);
props.refetch();
} catch (e) {
console.error(e);
showToast(eify(e));
}
setUpdatingFollowStatus(false);
}
return ( return (
<div class="fixed top-0 z-50 flex w-full max-w-[600px] flex-col gap-2 bg-m-grey-975/70 px-4 py-4 backdrop-blur-lg"> <div class="fixed top-0 z-50 flex w-full max-w-[600px] flex-col gap-2 bg-m-grey-975/70 px-4 py-4 backdrop-blur-lg">
<div class="backgrop-blur-lg z-50 bg-m-grey-975/70 safe-top" /> <div class="backgrop-blur-lg z-50 bg-m-grey-975/70 safe-top" />
@@ -341,13 +378,39 @@ function FixedChatHeader(props: {
<ArrowUpRight class="inline-block" /> <ArrowUpRight class="inline-block" />
<span>Send</span> <span>Send</span>
</button> </button>
<button <Show when={props.contact?.npub}>
class="flex gap-2 font-semibold text-m-blue" <button
onClick={() => props.requestFromContact(props.contact)} class="flex gap-2 font-semibold text-m-blue"
> onClick={() =>
<ArrowDownLeft class="inline-block text-m-blue" /> props.requestFromContact(props.contact)
<span>Request</span> }
</button> >
<ArrowDownLeft class="inline-block text-m-blue" />
<span>Request</span>
</button>
<Switch>
<Match when={props.contact?.is_followed}>
<button
class="flex gap-2 font-semibold text-m-red disabled:text-m-grey-350 disabled:opacity-50"
onClick={unfollowContact}
disabled={updatingFollowStatus()}
>
<X class="inline-block text-m-red" />
<span>Unfollow</span>
</button>
</Match>
<Match when={!props.contact?.is_followed}>
<button
class="flex gap-2 font-semibold text-white disabled:text-m-grey-350 disabled:opacity-50"
onClick={followContact}
disabled={updatingFollowStatus()}
>
<Check class="inline-block text-white" />
<span>Follow</span>
</button>
</Match>
</Switch>
</Show>
</div> </div>
</div> </div>
</div> </div>
@@ -363,7 +426,17 @@ export function Chat() {
const i18n = useI18n(); const i18n = useI18n();
const contact = createAsync(async () => { // const contact = createAsync(async () => {
// try {
// return state.mutiny_wallet?.get_tag_item(params.id);
// } catch (e) {
// console.error("couldn't find contact");
// console.error(e);
// return undefined;
// }
// });
const [contact, { refetch: refetchContact }] = createResource(async () => {
try { try {
return state.mutiny_wallet?.get_tag_item(params.id); return state.mutiny_wallet?.get_tag_item(params.id);
} catch (e) { } catch (e) {
@@ -417,13 +490,13 @@ export function Chat() {
const a_time = isDirectMessage(a.content) const a_time = isDirectMessage(a.content)
? a.content.date ? a.content.date
: isActivityItem(a.content) : isActivityItem(a.content)
? a.content.last_updated ? a.content.last_updated
: 0; : 0;
const b_time = isDirectMessage(b.content) const b_time = isDirectMessage(b.content)
? b.content.date ? b.content.date
: isActivityItem(b.content) : isActivityItem(b.content)
? b.content.last_updated ? b.content.last_updated
: 0; : 0;
return b_time - a_time; // Descending order return b_time - a_time; // Descending order
}); });
@@ -512,7 +585,7 @@ export function Chat() {
<Show when={contact()}> <Show when={contact()}>
<FixedChatHeader <FixedChatHeader
contact={contact()!} contact={contact()!}
refetch={refetch} refetch={refetchContact}
requestFromContact={requestFromContact} requestFromContact={requestFromContact}
sendToContact={sendToContact} sendToContact={sendToContact}
/> />
@@ -540,7 +613,7 @@ export function Chat() {
contact={contact()!} contact={contact()!}
/> />
</Match> </Match>
<Match when={true}> <Match when={contact() && contact()?.npub}>
<ButtonCard <ButtonCard
onClick={() => onClick={() =>
requestFromContact(contact()) requestFromContact(contact())
@@ -561,45 +634,49 @@ export function Chat() {
</Show> </Show>
</Suspense> </Suspense>
</div> </div>
<div class="fixed bottom-0 grid w-full max-w-[600px] grid-cols-[auto_1fr_auto] grid-rows-1 items-center gap-2 bg-m-grey-975/70 px-4 py-2 backdrop-blur-lg"> <Show when={contact() && contact()?.npub}>
<MiniFab <div class="fixed bottom-0 grid w-full max-w-[600px] grid-cols-[auto_1fr_auto] grid-rows-1 items-center gap-2 bg-m-grey-975/70 px-4 py-2 backdrop-blur-lg">
onScan={() => navigate("/scanner")} <MiniFab
onSend={() => { onScan={() => navigate("/scanner")}
sendToContact(contact()); onSend={() => {
}} sendToContact(contact());
sendDisabled={ }}
!contact() || sendDisabled={
!(contact()?.ln_address || contact()?.lnurl) !contact() ||
} !(contact()?.ln_address || contact()?.lnurl)
onRequest={() => requestFromContact(contact())} }
/> onRequest={() => requestFromContact(contact())}
<form
onSubmit={async (e) => {
e.preventDefault();
await sendMessage();
}}
>
<SimpleInput
disabled={sending()}
value={messageValue()}
onInput={(e) => setMessageValue(e.currentTarget.value)}
placeholder={i18n.t("chat.placeholder")}
/> />
</form> <form
<div> onSubmit={async (e) => {
<Show when={messageValue() || sending()}> e.preventDefault();
<Button await sendMessage();
layout="xs" }}
intent="blue" >
loading={sending()} <SimpleInput
onClick={sendMessage} disabled={sending()}
> value={messageValue()}
Send onInput={(e) =>
</Button> setMessageValue(e.currentTarget.value)
</Show> }
placeholder={i18n.t("chat.placeholder")}
/>
</form>
<div>
<Show when={messageValue() || sending()}>
<Button
layout="xs"
intent="blue"
loading={sending()}
onClick={sendMessage}
>
Send
</Button>
</Show>
</div>
<div class="backgrop-blur-lg z-50 bg-m-grey-975/70 safe-bottom" />
</div> </div>
<div class="backgrop-blur-lg z-50 bg-m-grey-975/70 safe-bottom" /> </Show>
</div>
</MutinyWalletGuard> </MutinyWalletGuard>
); );
} }

View File

@@ -3,16 +3,13 @@ import { createMemo, createSignal, Show } from "solid-js";
import { import {
BackLink, BackLink,
ButtonLink,
DefaultMain, DefaultMain,
EditableProfile, EditableProfile,
EditProfileForm, EditProfileForm,
LargeHeader, LargeHeader,
MutinyWalletGuard, MutinyWalletGuard,
NavBar, NavBar
NiceP
} from "~/components"; } from "~/components";
// import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
export function EditProfile() { export function EditProfile() {
@@ -40,7 +37,7 @@ export function EditProfile() {
const newProfile = await state.mutiny_wallet?.edit_nostr_profile( const newProfile = await state.mutiny_wallet?.edit_nostr_profile(
profile.nym, profile.nym,
profile.imageUrl, profile.imageUrl,
originalProfile().lud16, profile.lightningAddress,
originalProfile().nip05 originalProfile().nip05
); );
@@ -58,16 +55,12 @@ export function EditProfile() {
<DefaultMain> <DefaultMain>
<BackLink href="/profile" title="Profile" /> <BackLink href="/profile" title="Profile" />
<LargeHeader>Edit Profile</LargeHeader> <LargeHeader>Edit Profile</LargeHeader>
<NiceP>
Update your profile.
<br />
Your activity is private by default.
</NiceP>
<div class="flex-1" /> <div class="flex-1" />
<Show when={originalProfile()}> <Show when={originalProfile()}>
<EditProfileForm <EditProfileForm
initialProfile={{ initialProfile={{
nym: originalProfile().name, nym: originalProfile().name,
lightningAddress: originalProfile().lud16,
imageUrl: originalProfile().picture imageUrl: originalProfile().picture
}} }}
onSave={saveProfile} onSave={saveProfile}
@@ -75,10 +68,6 @@ export function EditProfile() {
cta="Save" cta="Save"
/> />
</Show> </Show>
<ButtonLink href="/importprofile" intent="text">
Import different nostr profile
</ButtonLink>
<NavBar activeTab="profile" /> <NavBar activeTab="profile" />
</DefaultMain> </DefaultMain>
</MutinyWalletGuard> </MutinyWalletGuard>

View File

@@ -1,21 +1,9 @@
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { SecureStoragePlugin } from "capacitor-secure-storage-plugin"; import { Show } from "solid-js";
import { createSignal, Show } from "solid-js";
import { import { Button, ButtonLink, DefaultMain, ImportNsecForm } from "~/components";
Button,
ButtonLink,
DefaultMain,
InfoBox,
SimpleInput
} from "~/components";
export function ImportProfile() { export function ImportProfile() {
const [nsec, setNsec] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal<string | undefined>();
const navigate = useNavigate(); const navigate = useNavigate();
function handleSkip() { function handleSkip() {
@@ -23,25 +11,6 @@ export function ImportProfile() {
navigate("/"); navigate("/");
} }
async function saveNsec() {
setSaving(true);
setError(undefined);
const trimmedNsec = nsec().trim();
try {
const npub = await MutinyWallet.nsec_to_npub(trimmedNsec);
if (!npub) {
throw new Error("Invalid nsec");
}
await SecureStoragePlugin.set({ key: "nsec", value: trimmedNsec });
// TODO: right now we need a reload to set the nsec
window.location.href = "/";
} catch (e) {
console.error(e);
setError("Invalid nsec");
}
setSaving(false);
}
// @ts-expect-error we're checking for an extension // @ts-expect-error we're checking for an extension
const windowHasNostr = window.nostr && window.nostr.getPublicKey; const windowHasNostr = window.nostr && window.nostr.getPublicKey;
@@ -55,17 +24,7 @@ export function ImportProfile() {
<br /> <br />
</p> </p>
<div class="flex-1" /> <div class="flex-1" />
<SimpleInput <ImportNsecForm />
value={nsec()}
onInput={(e) => setNsec(e.currentTarget.value)}
placeholder={`Nostr private key (starts with "nsec")`}
/>
<Button layout="full" onClick={saveNsec} loading={saving()}>
Import
</Button>
<Show when={error()}>
<InfoBox accent="red">{error()}</InfoBox>
</Show>
<div class="flex-1" /> <div class="flex-1" />
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
{/* Don't want them to accidentally "edit" their profile if they have one */} {/* Don't want them to accidentally "edit" their profile if they have one */}

View File

@@ -20,7 +20,6 @@ import { useMegaStore } from "~/state/megaStore";
export function WalletHeader(props: { loading: boolean }) { export function WalletHeader(props: { loading: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
const npub = () => state.mutiny_wallet?.get_npub();
async function getProfile() { async function getProfile() {
const profile = state.mutiny_wallet?.get_nostr_profile(); const profile = state.mutiny_wallet?.get_nostr_profile();
@@ -44,7 +43,7 @@ export function WalletHeader(props: { loading: boolean }) {
return profile()!.picture; return profile()!.picture;
} }
return `https://bitcoinfaces.xyz/api/get-image?name=${npub()}&onchain=false`; return undefined;
}); });
return ( return (

View File

@@ -1,9 +1,8 @@
import { useNavigate } from "@solidjs/router"; import { A, useNavigate } from "@solidjs/router";
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { import {
Button, Button,
ButtonLink,
DefaultMain, DefaultMain,
EditableProfile, EditableProfile,
EditProfileForm EditProfileForm
@@ -28,7 +27,7 @@ export function NewProfile() {
const profile = await state.mutiny_wallet?.edit_nostr_profile( const profile = await state.mutiny_wallet?.edit_nostr_profile(
p.nym ? p.nym : undefined, p.nym ? p.nym : undefined,
p.imageUrl ? p.imageUrl : undefined, p.imageUrl ? p.imageUrl : undefined,
undefined, p.lightningAddress ? p.lightningAddress : undefined,
undefined undefined
); );
console.log("profile", profile); console.log("profile", profile);
@@ -47,8 +46,6 @@ export function NewProfile() {
<h1 class="text-3xl font-semibold">Create your profile</h1> <h1 class="text-3xl font-semibold">Create your profile</h1>
<p class="text-center text-xl font-light text-neutral-200"> <p class="text-center text-xl font-light text-neutral-200">
Mutiny makes payments social. Mutiny makes payments social.
<br />
Your activity is private by default.
</p> </p>
<div class="flex-1" /> <div class="flex-1" />
<EditProfileForm <EditProfileForm
@@ -56,14 +53,15 @@ export function NewProfile() {
saving={creating()} saving={creating()}
cta="Create" cta="Create"
/> />
<div class="flex flex-col items-center"> <Button onClick={handleSkip} intent="text">
<ButtonLink href="/importprofile" intent="text"> Skip for now
Import existing nostr profile </Button>
</ButtonLink> <A
<Button onClick={handleSkip} intent="text"> class="text-base font-normal text-m-grey-400"
Skip for now href="/importprofile"
</Button> >
</div> Import existing nostr profile
</A>
<div class="flex-1" /> <div class="flex-1" />
</div> </div>
</DefaultMain> </DefaultMain>

View File

@@ -1,26 +1,29 @@
import { A } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { createMemo, Show } from "solid-js"; import { Copy, Edit, QrCode } from "lucide-solid";
import { createMemo, createSignal, Match, Show, Switch } from "solid-js";
import { QRCodeSVG } from "solid-qr-code"; import { QRCodeSVG } from "solid-qr-code";
import { import {
BackLink, BackLink,
BalanceBox, BalanceBox,
ButtonCard,
DefaultMain, DefaultMain,
FancyCard, FancyCard,
KeyValue,
LabelCircle, LabelCircle,
MiniStringShower,
MutinyWalletGuard, MutinyWalletGuard,
NavBar NavBar,
NiceP,
SimpleDialog
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { useCopy } from "~/utils";
export function Profile() { export function Profile() {
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
const i18n = useI18n(); const i18n = useI18n();
const npub = () => state.mutiny_wallet?.get_npub(); const navigate = useNavigate();
const profile = createMemo(() => { const profile = createMemo(() => {
const profile = state.mutiny_wallet?.get_nostr_profile(); const profile = state.mutiny_wallet?.get_nostr_profile();
@@ -34,6 +37,10 @@ export function Profile() {
}; };
}); });
const [showQr, setShowQr] = createSignal(false);
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
return ( return (
<MutinyWalletGuard> <MutinyWalletGuard>
<DefaultMain> <DefaultMain>
@@ -43,50 +50,79 @@ export function Profile() {
<LabelCircle <LabelCircle
contact contact
label={false} label={false}
name={profile().name ? profile().name : "Anon"} image_url={profile().picture}
image_url={
profile().picture
? profile().picture
: `https://bitcoinfaces.xyz/api/get-image?name=${npub()}&onchain=false`
}
size="xl" size="xl"
/> />
<h1 class="text-3xl font-semibold"> <h1 class="text-3xl font-semibold">
<Show when={profile().name}>{profile().name}</Show> <Show when={profile().name}>{profile().name}</Show>
</h1> </h1>
<Show when={profile().lud16}> <FancyCard>
<p class="break-all text-center font-system-mono text-base text-m-grey-350"> <Switch>
{profile().lud16} <Match when={profile().lud16}>
</p> <p class="break-all text-center font-system-mono text-base ">
<div class="w-[10rem] rounded bg-white p-[1rem]"> {profile().lud16}
<QRCodeSVG </p>
value={profile().lud16} <div class="flex w-full justify-center gap-8">
class="h-full max-h-[256px] w-full" <button onClick={() => setShowQr(true)}>
/> <QrCode class="inline-block" />
</div> </button>
</Show> <button
<Show when={!profile().lud16}> class="p-1"
<p class="text-center text-base italic text-m-grey-350"> classList={{
Mutiny Lightning Address coming soon. "bg-m-red rounded": copied()
</p> }}
</Show> onClick={() =>
copy(profile().lud16)
<A }
href="/editprofile" >
class="text-xl font-semibold text-m-red no-underline active:text-m-red/80" <Copy class="inline-block" />
> </button>
{i18n.t("profile.edit_profile")} </div>{" "}
</A> <SimpleDialog
open={showQr()}
setOpen={(open) => {
setShowQr(open);
}}
title={"Lightning Address"}
>
<div class="w-[10rem] self-center rounded bg-white p-[1rem]">
<QRCodeSVG
value={profile().lud16 || ""}
class="h-full max-h-[256px] w-full"
/>
</div>
</SimpleDialog>
</Match>
<Match when={true}>
<p class="text-center text-base italic text-m-grey-350">
No Lightning Address set
</p>
</Match>
</Switch>
</FancyCard>
</Show> </Show>
</div> </div>
{/* <LargeHeader>Accounts</LargeHeader> */} {/* <div>
<MediumHeader>{i18n.t("profile.social")}</MediumHeader>
<FancyCard>
<KeyValue key="LN Address">
<MiniStringShower text={profile().lud16 || ""} />
</KeyValue>
<KeyValue key="npub">
<MiniStringShower text={npub() || ""} />
</KeyValue>
</FancyCard>
</div> */}
<ButtonCard onClick={() => navigate("/editprofile")}>
<div class="flex items-center gap-2">
{/* <Users class="inline-block text-m-red" /> */}
<Edit class="inline-block text-m-red" />
<NiceP>{i18n.t("profile.edit_profile")}</NiceP>
</div>
</ButtonCard>
<BalanceBox loading={state.wallet_loading} /> <BalanceBox loading={state.wallet_loading} />
<FancyCard title={i18n.t("profile.nostr_identity")}>
<KeyValue key="npub">
<MiniStringShower text={npub() || ""} />
</KeyValue>
</FancyCard>
<NavBar activeTab="profile" /> <NavBar activeTab="profile" />
</DefaultMain> </DefaultMain>
</MutinyWalletGuard> </MutinyWalletGuard>

View File

@@ -184,8 +184,8 @@ export function Receive() {
? paymentTx()?.txid ? paymentTx()?.txid
: undefined : undefined
: paymentInvoice() : paymentInvoice()
? paymentInvoice()?.payment_hash ? paymentInvoice()?.payment_hash
: undefined; : undefined;
const kind = paidState() === "onchain_paid" ? "OnChain" : "Lightning"; const kind = paidState() === "onchain_paid" ? "OnChain" : "Lightning";
console.log("Opening details modal: ", paymentTxId, kind); console.log("Opening details modal: ", paymentTxId, kind);

View File

@@ -201,8 +201,8 @@ export function Send() {
? sentDetails()?.txid ? sentDetails()?.txid
: undefined : undefined
: sentDetails() : sentDetails()
? sentDetails()?.payment_hash ? sentDetails()?.payment_hash
: undefined; : undefined;
const kind = sentDetails()?.txid ? "OnChain" : "Lightning"; const kind = sentDetails()?.txid ? "OnChain" : "Lightning";
console.log("Opening details modal: ", paymentTxId, kind); console.log("Opening details modal: ", paymentTxId, kind);

View File

@@ -1,12 +1,12 @@
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
import { ButtonLink, LargeHeader, VStack } from "~/components"; import { ButtonLink, DefaultMain, LargeHeader } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
export function NotFound() { export function NotFound() {
const i18n = useI18n(); const i18n = useI18n();
return ( return (
<VStack> <DefaultMain>
<Title>{i18n.t("error.not_found.title")}</Title> <Title>{i18n.t("error.not_found.title")}</Title>
<LargeHeader>{i18n.t("error.not_found.title")}</LargeHeader> <LargeHeader>{i18n.t("error.not_found.title")}</LargeHeader>
<p>{i18n.t("error.not_found.wtf_paul")}</p> <p>{i18n.t("error.not_found.wtf_paul")}</p>
@@ -14,6 +14,6 @@ export function NotFound() {
<ButtonLink href="/" intent="red"> <ButtonLink href="/" intent="red">
{i18n.t("common.dangit")} {i18n.t("common.dangit")}
</ButtonLink> </ButtonLink>
</VStack> </DefaultMain>
); );
} }

View File

@@ -0,0 +1,70 @@
import { A } from "@solidjs/router";
import { Show } from "solid-js";
import { QRCodeSVG } from "solid-qr-code";
import {
BackPop,
DefaultMain,
ExternalLink,
FancyCard,
KeyValue,
LargeHeader,
MiniStringShower,
MutinyWalletGuard,
NavBar,
NiceP,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
export function NostrKeys() {
const i18n = useI18n();
const [state, _actions] = useMegaStore();
const npub = () => state.mutiny_wallet?.get_npub();
const nsec = () => state.mutiny_wallet?.export_nsec();
return (
<MutinyWalletGuard>
<DefaultMain>
<BackPop default="/settings" />
<LargeHeader>{i18n.t("settings.nostr_keys.title")}</LargeHeader>
<NiceP>{i18n.t("settings.nostr_keys.description")}</NiceP>
<NiceP>
<ExternalLink href="https://nostr.com/">
{i18n.t("settings.nostr_keys.learn_more")}
</ExternalLink>
</NiceP>
<FancyCard>
<VStack>
<div class="w-[10rem] self-center rounded bg-white p-[1rem]">
<QRCodeSVG
value={npub() || ""}
class="h-full max-h-[256px] w-full"
/>
</div>
<KeyValue key="Public Key">
<MiniStringShower text={npub() || ""} />
</KeyValue>
<Show when={nsec()}>
<KeyValue key="Private Key">
<MiniStringShower text={nsec() || ""} hide />
</KeyValue>
<p class="text-base italic text-m-grey-350">
{i18n.t("settings.nostr_keys.warning")}
</p>
</Show>
</VStack>
</FancyCard>
<A
href="/importprofile"
class="self-center text-base font-normal text-m-grey-400"
>
Import different nostr profile
</A>
</DefaultMain>
<NavBar activeTab="settings" />
</MutinyWalletGuard>
);
}

View File

@@ -110,6 +110,17 @@ export function Settings() {
? i18n.t("settings.encrypt.caption") ? i18n.t("settings.encrypt.caption")
: undefined : undefined
}, },
{
href: "/settings/servers",
text: i18n.t("settings.servers.title"),
caption: i18n.t("settings.servers.caption")
}
]}
/>
<SettingsLinkList
header={i18n.t("settings.appearance")}
links={[
{ {
href: "/settings/currency", href: "/settings/currency",
text: i18n.t("settings.currency.title"), text: i18n.t("settings.currency.title"),
@@ -119,24 +130,16 @@ export function Settings() {
href: "/settings/language", href: "/settings/language",
text: i18n.t("settings.language.title"), text: i18n.t("settings.language.title"),
caption: i18n.t("settings.language.caption") caption: i18n.t("settings.language.caption")
},
{
href: "/settings/servers",
text: i18n.t("settings.servers.title"),
caption: i18n.t("settings.servers.caption")
} }
]} ]}
/> />
<SettingsLinkList <SettingsLinkList
header={i18n.t("settings.experimental_features")} header={i18n.t("settings.social")}
links={[ links={[
{ {
href: "/settings/syncnostrcontacts", href: "/settings/nostrkeys",
text: i18n.t("settings.nostr_contacts.title") text: i18n.t("settings.nostr_keys.title"),
}, caption: i18n.t("settings.nostr_keys.caption")
{
href: "/settings/federations",
text: i18n.t("settings.manage_federations.title")
} }
]} ]}
/> />

View File

@@ -1,170 +0,0 @@
import { Capacitor } from "@capacitor/core";
import { createForm, required, SubmitHandler } from "@modular-forms/solid";
import { SecureStoragePlugin } from "capacitor-secure-storage-plugin";
import { createSignal, Match, Show, Switch } from "solid-js";
import {
BackPop,
Button,
DefaultMain,
FancyCard,
InfoBox,
KeyValue,
LargeHeader,
MiniStringShower,
MutinyWalletGuard,
NavBar,
TextField,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import { eify } from "~/utils";
type NostrContactsForm = {
npub: string;
};
function SyncContactsForm() {
const i18n = useI18n();
const [state, actions] = useMegaStore();
const [error, setError] = createSignal<Error>();
const allowNsec = Capacitor.isNativePlatform();
const [feedbackForm, { Form, Field }] = createForm<NostrContactsForm>({
initialValues: {
npub: ""
}
});
const handleSubmit: SubmitHandler<NostrContactsForm> = async (
f: NostrContactsForm
) => {
try {
const npub = f.npub.trim();
await state.mutiny_wallet?.sync_nostr_contacts(npub);
actions.saveNpub(npub);
} catch (e) {
console.error(e);
setError(eify(e));
}
};
return (
<Form onSubmit={handleSubmit}>
<VStack>
<Field
name="npub"
validate={[
required(
i18n.t("settings.nostr_contacts.npub_required")
)
]}
>
{(field, props) => (
<TextField
{...props}
value={field.value}
error={field.error}
label={i18n.t("settings.nostr_contacts.npub_label")}
placeholder={allowNsec ? "npub/nsec..." : "npub..."}
/>
)}
</Field>
<Show when={error()}>
<InfoBox accent="red">{error()?.message}</InfoBox>
</Show>
<Button
loading={feedbackForm.submitting}
disabled={
!feedbackForm.dirty ||
feedbackForm.submitting ||
feedbackForm.invalid
}
intent="blue"
type="submit"
>
{i18n.t("settings.nostr_contacts.sync")}
</Button>
</VStack>
</Form>
);
}
export function SyncNostrContacts() {
const i18n = useI18n();
const [state, actions] = useMegaStore();
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<Error>();
async function clearNpub() {
actions.saveNpub("");
if (Capacitor.isNativePlatform()) {
await SecureStoragePlugin.remove({ key: "nsec" });
}
}
async function resync() {
setError(undefined);
setLoading(true);
try {
await state.mutiny_wallet?.sync_nostr_contacts(
// We can only see the resync button if there's an npub set
state.npub!
);
} catch (e) {
console.error(e);
}
setLoading(false);
}
return (
<MutinyWalletGuard>
<DefaultMain>
<BackPop default="/settings" />
<LargeHeader>
{i18n.t("settings.nostr_contacts.title")}
</LargeHeader>
<Switch>
<Match when={state.npub}>
<VStack>
<Show when={error()}>
<InfoBox accent="red">
{error()?.message}
</InfoBox>
</Show>
<FancyCard>
<VStack>
<KeyValue key="Npub">
<MiniStringShower
text={state.npub || ""}
/>
</KeyValue>
<Button
intent="blue"
onClick={resync}
loading={loading()}
>
{i18n.t(
"settings.nostr_contacts.resync"
)}
</Button>
<Button intent="red" onClick={clearNpub}>
{i18n.t(
"settings.nostr_contacts.remove"
)}
</Button>
</VStack>
</FancyCard>
</VStack>
</Match>
<Match when={!state.npub}>
<SyncContactsForm />
</Match>
</Switch>
</DefaultMain>
<NavBar activeTab="settings" />
</MutinyWalletGuard>
);
}

View File

@@ -11,5 +11,5 @@ export * from "./Gift";
export * from "./Plus"; export * from "./Plus";
export * from "./Restore"; export * from "./Restore";
export * from "./Servers"; export * from "./Servers";
export * from "./SyncNostrContacts";
export * from "./ManageFederations"; export * from "./ManageFederations";
export * from "./NostrKeys";

5
types/index.d.ts vendored
View File

@@ -1,5 +0,0 @@
declare module "*.svg" {
const content: any;
export default content;
}