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 { loadHome, visitSettings } from "./utils";
import { loadHome } from "./utils";
const SIGNET_INVITE_CODE =
"fed11qgqzc2nhwden5te0vejkg6tdd9h8gepwvejkg6tdd9h8garhduhx6at5d9h8jmn9wshxxmmd9uqqzgxg6s3evnr6m9zdxr6hxkdkukexpcs3mn7mj3g5pc5dfh63l4tj6g9zk4er";
@@ -11,10 +11,9 @@ test.beforeEach(async ({ page }) => {
test("fedmint join, receive, send", async ({ page }) => {
await loadHome(page);
await visitSettings(page);
// Click "Manage Federations" link
await page.click("text=Manage Federations");
// Click "Join a federation" cta
await page.click("text=Join a federation");
// Fill the input with the federation 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
await page.goBack();
await page.goBack();
// Click the top left button (it's the profile button), a child of header
// TODO: better ARIA stuff

View File

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

View File

@@ -52,19 +52,19 @@
"@capacitor/share": "^5.0.6",
"@capacitor/status-bar": "^5.0.6",
"@capacitor/toast": "^5.0.6",
"@kobalte/core": "^0.12.1",
"@kobalte/core": "^0.12.6",
"@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",
"@solid-primitives/upload": "^0.0.111",
"@solid-primitives/upload": "^0.0.117",
"@solidjs/meta": "^0.29.3",
"@solidjs/router": "^0.10.9",
"@solidjs/router": "^0.13.1",
"capacitor-secure-storage-plugin": "^0.9.0",
"i18next": "^23.10.1",
"i18next-browser-languagedetector": "^7.1.0",
"lucide-solid": "^0.330.0",
"lucide-solid": "^0.363.0",
"qr-scanner": "^1.4.2",
"solid-js": "^1.8.12",
"solid-js": "^1.8.16",
"solid-qr-code": "^0.0.8",
"solid-transition-group": "^0.2.3"
},

3060
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,15 @@
"profile": "Profile",
"nostr_identity": "Nostr Identity",
"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": {
"prompt": "This is a new conversation. Try asking for money!",
@@ -74,8 +82,7 @@
"email_error": "That doesn't look like a lightning address",
"npub_error": "That doesn't look like a nostr npub",
"error_ln_address_missing": "New contacts need a lightning address",
"npub": "Nostr Npub",
"link_to_nostr_sync": "Import Nostr Contacts"
"npub": "Nostr Npub"
},
"redeem": {
"redeem_bitcoin": "Redeem Bitcoin",
@@ -533,7 +540,7 @@
"remove": "Remove",
"expires": "Expires",
"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."
},
"gift": {
@@ -567,6 +574,15 @@
"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.",
"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": {

View File

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

View File

@@ -152,7 +152,9 @@ function OnchainHeader(props: { info: OnChainTx; kind?: HackActivityType }) {
? i18n.t("activity.transaction_details.channel_close")
: isSend()
? i18n.t("activity.transaction_details.onchain_send")
: i18n.t("activity.transaction_details.onchain_receive")}
: i18n.t(
"activity.transaction_details.onchain_receive"
)}
<Switch>
<Match
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 });
return (
<div class="grid w-full grid-cols-[minmax(0,_1fr)_auto] gap-1">
<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
class="w-[1.5rem] p-1"
classList={{ "bg-m-green rounded": copied() }}
classList={{ "bg-m-red rounded": copied() }}
onClick={() => copy(props.text)}
>
<Copy class="h-4 w-4" />
@@ -302,8 +318,8 @@ function OnchainDetails(props: {
await (state.mutiny_wallet?.list_channels() as Promise<
MutinyChannel[]
>);
const channel = channels.find(
(channel) => channel.outpoint?.startsWith(props.info.txid)
const channel = channels.find((channel) =>
channel.outpoint?.startsWith(props.info.txid)
);
return channel;
} catch (e) {
@@ -492,9 +508,8 @@ export function ActivityDetailsModal(props: {
try {
if (kind() === "Lightning") {
console.debug("reading invoice: ", id());
const invoice = await state.mutiny_wallet?.get_invoice_by_hash(
id()
);
const invoice =
await state.mutiny_wallet?.get_invoice_by_hash(id());
return invoice;
} else if (kind() === "ChannelClose") {
console.debug("reading channel close: ", id());

View File

@@ -1,13 +1,16 @@
import { A } from "@solidjs/router";
import { Shuffle } from "lucide-solid";
import { A, useNavigate } from "@solidjs/router";
import { Shuffle, Users } from "lucide-solid";
import { Match, Show, Switch } from "solid-js";
import {
AmountFiat,
AmountSats,
ButtonCard,
FancyCard,
Indicator,
InfoBox,
MediumHeader,
NiceP,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
@@ -45,6 +48,7 @@ const STYLE =
export function BalanceBox(props: { loading?: boolean; small?: boolean }) {
const [state, _actions] = useMegaStore();
const navigate = useNavigate();
const i18n = useI18n();
const totalOnchain = () =>
@@ -57,7 +61,80 @@ export function BalanceBox(props: { loading?: boolean; small?: boolean }) {
return (
<VStack>
<FancyCard title="Lightning">
<Switch>
<Match when={state.federations && state.federations.length}>
<div>
<MediumHeader>Fedimint</MediumHeader>
<FancyCard>
<Show
when={!props.loading}
fallback={<LoadingShimmer />}
>
<div class="flex justify-between">
<div class="flex flex-col gap-1">
<div class="text-2xl">
<AmountSats
amountSats={
state.balance?.federation ||
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
}
>
<div class="self-end justify-self-end">
<A
href="/swaplightning"
class={STYLE}
>
<Shuffle class="h-6 w-6" />
</A>
</div>
</Show>
</div>
</Show>
</FancyCard>
</div>
<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>
</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}>
@@ -90,45 +167,7 @@ export function BalanceBox(props: { loading?: boolean; small?: boolean }) {
</Match>
</Switch>
</Show>
</FancyCard>
<Show when={state.federations && state.federations.length}>
<FancyCard title="Fedimint">
<Show when={!props.loading} fallback={<LoadingShimmer />}>
<div class="flex justify-between">
<div class="flex flex-col gap-1">
<div class="text-2xl">
<AmountSats
amountSats={
state.balance?.federation || 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}>
<div class="self-end justify-self-end">
<A href="/swaplightning" class={STYLE}>
<Shuffle class="h-6 w-6" />
</A>
</div>
</Show>
</div>
</Show>
</FancyCard>
</Show>
<FancyCard title="On-chain">
{/* <hr class="my-2 border-m-grey-750" /> */}
<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">
@@ -166,6 +205,7 @@ export function BalanceBox(props: { loading?: boolean; small?: boolean }) {
</div>
</Show>
</FancyCard>
</div>
</VStack>
);
}

View File

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

View File

@@ -1,5 +1,4 @@
import { SubmitHandler } from "@modular-forms/solid";
import { A } from "@solidjs/router";
import { createSignal, Match, Switch } from "solid-js";
import {
@@ -61,15 +60,6 @@ export function ContactEditor(props: {
cta={i18n.t("contacts.create_contact")}
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>
</>
);

View File

@@ -1,12 +1,16 @@
import { createForm, email } from "@modular-forms/solid";
import { createFileUploader } from "@solid-primitives/upload";
import { Pencil } from "lucide-solid";
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 { blobToBase64 } from "~/utils";
export type EditableProfile = {
nym?: string;
lightningAddress?: string;
imageUrl?: string;
};
@@ -17,7 +21,6 @@ export function EditProfileForm(props: {
cta: string;
}) {
const [state] = useMegaStore();
const [nym, setNym] = createSignal(props.initialProfile?.nym || "");
const [uploading, setUploading] = createSignal(false);
const { files, selectFiles } = createFileUploader({
@@ -26,14 +29,20 @@ export function EditProfileForm(props: {
});
async function uploadFile() {
selectFiles(async (files) => {
console.log("uploadFile");
await selectFiles(async (files) => {
if (files.length) {
return;
}
});
}
async function onSave() {
const i18n = useI18n();
const [profileForm, { Form, Field }] = createForm<EditableProfile>({
initialValues: { ...props.initialProfile }
});
async function handleSubmit(profile: EditableProfile) {
try {
let imageUrl;
if (files() && files().length) {
@@ -46,7 +55,8 @@ export function EditProfileForm(props: {
setUploading(false);
}
await props.onSave({
nym: nym(),
nym: profile.nym,
lightningAddress: profile.lightningAddress,
imageUrl: imageUrl ? imageUrl : props.initialProfile?.imageUrl
});
} catch (e) {
@@ -56,10 +66,9 @@ export function EditProfileForm(props: {
return (
<>
<button
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"
onClick={uploadFile}
>
<VStack>
<button class="relative self-center" 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>
<Match when={files() && files().length}>
<img src={files()[0].source} />
@@ -67,22 +76,57 @@ export function EditProfileForm(props: {
<Match when={props.initialProfile?.imageUrl}>
<img src={props.initialProfile?.imageUrl} />
</Match>
<Match when={true}>+</Match>
</Switch>
</div>
<div class="absolute top-0 flex h-[8rem] w-[8rem] items-center justify-center bg-m-grey-975/25">
<Pencil />
</div>
</button>
<SimpleInput
value={nym()}
onInput={(e) => setNym(e.currentTarget.value)}
placeholder="Your name or nym"
<VStack>
<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")}
/>
<div class="flex-1" />
)}
</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"
onClick={onSave}
loading={props.saving || uploading()}
type="submit"
loading={
props.saving ||
uploading() ||
profileForm.submitting
}
>
{props.cta}
</Button>
</Form>
</VStack>
</VStack>
<div class="flex-1" />
</>
);
}

View File

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

@@ -64,7 +64,11 @@ export function LabelCircle(props: {
return (
<Circle
background={props.image_url && !errored() ? "none" : bg()}
onClick={() => props.onClick && props.onClick()}
onClick={
props.onClick
? () => props.onClick && props.onClick()
: undefined
}
size={props.size}
>
<Switch>

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 (
<div
class="flex font-mono"

View File

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

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<{
biggap?: boolean;
smallgap?: boolean;

View File

@@ -16,11 +16,11 @@ body {
/* After load we need to remove the bg so the qr scanner can show through */
@apply text-white;
@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 {
@apply flex flex-1 flex-col;
@apply mx-auto flex w-full max-w-[600px] flex-1 flex-col;
}
@media (prefers-color-scheme: light) {

View File

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

View File

@@ -1,6 +1,13 @@
import { TagItem } from "@mutinywallet/mutiny-wasm";
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 {
createEffect,
createResource,
@@ -310,6 +317,36 @@ function FixedChatHeader(props: {
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 (
<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" />
@@ -341,13 +378,39 @@ function FixedChatHeader(props: {
<ArrowUpRight class="inline-block" />
<span>Send</span>
</button>
<Show when={props.contact?.npub}>
<button
class="flex gap-2 font-semibold text-m-blue"
onClick={() => props.requestFromContact(props.contact)}
onClick={() =>
props.requestFromContact(props.contact)
}
>
<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>
@@ -363,7 +426,17 @@ export function Chat() {
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 {
return state.mutiny_wallet?.get_tag_item(params.id);
} catch (e) {
@@ -512,7 +585,7 @@ export function Chat() {
<Show when={contact()}>
<FixedChatHeader
contact={contact()!}
refetch={refetch}
refetch={refetchContact}
requestFromContact={requestFromContact}
sendToContact={sendToContact}
/>
@@ -540,7 +613,7 @@ export function Chat() {
contact={contact()!}
/>
</Match>
<Match when={true}>
<Match when={contact() && contact()?.npub}>
<ButtonCard
onClick={() =>
requestFromContact(contact())
@@ -561,6 +634,7 @@ export function Chat() {
</Show>
</Suspense>
</div>
<Show when={contact() && contact()?.npub}>
<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">
<MiniFab
onScan={() => navigate("/scanner")}
@@ -582,7 +656,9 @@ export function Chat() {
<SimpleInput
disabled={sending()}
value={messageValue()}
onInput={(e) => setMessageValue(e.currentTarget.value)}
onInput={(e) =>
setMessageValue(e.currentTarget.value)
}
placeholder={i18n.t("chat.placeholder")}
/>
</form>
@@ -600,6 +676,7 @@ export function Chat() {
</div>
<div class="backgrop-blur-lg z-50 bg-m-grey-975/70 safe-bottom" />
</div>
</Show>
</MutinyWalletGuard>
);
}

View File

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

View File

@@ -1,21 +1,9 @@
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { useNavigate } from "@solidjs/router";
import { SecureStoragePlugin } from "capacitor-secure-storage-plugin";
import { createSignal, Show } from "solid-js";
import { Show } from "solid-js";
import {
Button,
ButtonLink,
DefaultMain,
InfoBox,
SimpleInput
} from "~/components";
import { Button, ButtonLink, DefaultMain, ImportNsecForm } from "~/components";
export function ImportProfile() {
const [nsec, setNsec] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal<string | undefined>();
const navigate = useNavigate();
function handleSkip() {
@@ -23,25 +11,6 @@ export function ImportProfile() {
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
const windowHasNostr = window.nostr && window.nostr.getPublicKey;
@@ -55,17 +24,7 @@ export function ImportProfile() {
<br />
</p>
<div class="flex-1" />
<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>
<ImportNsecForm />
<div class="flex-1" />
<div class="flex flex-col items-center">
{/* 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 }) {
const navigate = useNavigate();
const [state, _actions] = useMegaStore();
const npub = () => state.mutiny_wallet?.get_npub();
async function getProfile() {
const profile = state.mutiny_wallet?.get_nostr_profile();
@@ -44,7 +43,7 @@ export function WalletHeader(props: { loading: boolean }) {
return profile()!.picture;
}
return `https://bitcoinfaces.xyz/api/get-image?name=${npub()}&onchain=false`;
return undefined;
});
return (

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { Title } from "@solidjs/meta";
import { ButtonLink, LargeHeader, VStack } from "~/components";
import { ButtonLink, DefaultMain, LargeHeader } from "~/components";
import { useI18n } from "~/i18n/context";
export function NotFound() {
const i18n = useI18n();
return (
<VStack>
<DefaultMain>
<Title>{i18n.t("error.not_found.title")}</Title>
<LargeHeader>{i18n.t("error.not_found.title")}</LargeHeader>
<p>{i18n.t("error.not_found.wtf_paul")}</p>
@@ -14,6 +14,6 @@ export function NotFound() {
<ButtonLink href="/" intent="red">
{i18n.t("common.dangit")}
</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")
: 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",
text: i18n.t("settings.currency.title"),
@@ -119,24 +130,16 @@ export function Settings() {
href: "/settings/language",
text: i18n.t("settings.language.title"),
caption: i18n.t("settings.language.caption")
},
{
href: "/settings/servers",
text: i18n.t("settings.servers.title"),
caption: i18n.t("settings.servers.caption")
}
]}
/>
<SettingsLinkList
header={i18n.t("settings.experimental_features")}
header={i18n.t("settings.social")}
links={[
{
href: "/settings/syncnostrcontacts",
text: i18n.t("settings.nostr_contacts.title")
},
{
href: "/settings/federations",
text: i18n.t("settings.manage_federations.title")
href: "/settings/nostrkeys",
text: i18n.t("settings.nostr_keys.title"),
caption: i18n.t("settings.nostr_keys.caption")
}
]}
/>

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 "./Restore";
export * from "./Servers";
export * from "./SyncNostrContacts";
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;
}