This commit is contained in:
Paul Miller
2023-08-09 17:16:55 -05:00
parent f0f60b4e00
commit 058f4c402b
23 changed files with 1342 additions and 279 deletions

View File

@@ -17,3 +17,4 @@ VITE_SUBSCRIPTIONS="https://subscriptions-staging.mutinywallet.com"
VITE_STORAGE="https://storage-staging.mutinywallet.com" VITE_STORAGE="https://storage-staging.mutinywallet.com"
VITE_FEEDBACK="https://feedback-staging.mutinywallet.com" VITE_FEEDBACK="https://feedback-staging.mutinywallet.com"
VITE_SCORER="https://scorer-staging.mutinywallet.com" VITE_SCORER="https://scorer-staging.mutinywallet.com"
VITE_PRIMAL="https://primal-cache.mutinywallet.com/api"

View File

@@ -74,6 +74,7 @@ jobs:
VITE_STORAGE: https://storage-staging.mutinywallet.com VITE_STORAGE: https://storage-staging.mutinywallet.com
VITE_FEEDBACK: https://feedback-staging.mutinywallet.com VITE_FEEDBACK: https://feedback-staging.mutinywallet.com
VITE_SCORER: https://scorer-staging.mutinywallet.com VITE_SCORER: https://scorer-staging.mutinywallet.com
VITE_PRIMAL: https://primal-cache.mutinywallet.com/api
run: pnpm build run: pnpm build
- name: Capacitor sync - name: Capacitor sync

View File

@@ -67,6 +67,7 @@ jobs:
VITE_STORAGE: https://storage.mutinywallet.com VITE_STORAGE: https://storage.mutinywallet.com
VITE_FEEDBACK: https://feedback.mutinywallet.com VITE_FEEDBACK: https://feedback.mutinywallet.com
VITE_SCORER: https://scorer.mutinywallet.com VITE_SCORER: https://scorer.mutinywallet.com
VITE_PRIMAL: https://primal-cache.mutinywallet.com/api
run: pnpm build run: pnpm build
- name: Capacitor sync - name: Capacitor sync

View File

@@ -67,6 +67,7 @@ jobs:
VITE_STORAGE: https://storage-staging.mutinywallet.com VITE_STORAGE: https://storage-staging.mutinywallet.com
VITE_FEEDBACK: https://feedback-staging.mutinywallet.com VITE_FEEDBACK: https://feedback-staging.mutinywallet.com
VITE_SCORER: https://scorer-staging.mutinywallet.com VITE_SCORER: https://scorer-staging.mutinywallet.com
VITE_PRIMAL: https://primal-cache.mutinywallet.com/api
run: pnpm build run: pnpm build
- name: Capacitor sync - name: Capacitor sync

View File

@@ -64,6 +64,7 @@ jobs:
VITE_STORAGE: https://storage-staging.mutinywallet.com VITE_STORAGE: https://storage-staging.mutinywallet.com
VITE_FEEDBACK: https://feedback-staging.mutinywallet.com VITE_FEEDBACK: https://feedback-staging.mutinywallet.com
VITE_SCORER: https://scorer-staging.mutinywallet.com VITE_SCORER: https://scorer-staging.mutinywallet.com
VITE_PRIMAL: https://primal-cache.mutinywallet.com/api
run: pnpm exec playwright test run: pnpm exec playwright test
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()

View File

@@ -57,7 +57,9 @@
"@mutinywallet/mutiny-wasm": "0.4.16", "@mutinywallet/mutiny-wasm": "0.4.16",
"@mutinywallet/ui": "workspace:*", "@mutinywallet/ui": "workspace:*",
"@mutinywallet/waila-wasm": "^0.2.1", "@mutinywallet/waila-wasm": "^0.2.1",
"@nostr-dev-kit/ndk": "^0.8.11",
"@solid-primitives/upload": "^0.0.111", "@solid-primitives/upload": "^0.0.111",
"@solid-primitives/websocket": "^1.1.0",
"@thisbeyond/solid-select": "^0.14.0", "@thisbeyond/solid-select": "^0.14.0",
"i18next": "^22.5.1", "i18next": "^22.5.1",
"i18next-browser-languagedetector": "^7.1.0", "i18next-browser-languagedetector": "^7.1.0",

View File

@@ -72,3 +72,13 @@ select {
strong { strong {
@apply font-semibold text-m-red; @apply font-semibold text-m-red;
} }
.slide-fade-enter-active,
.slide-fade-exit-active {
transition: all 0.3s ease;
}
.slide-fade-enter,
.slide-fade-exit-to {
transform: translateX(10px);
opacity: 0;
}

832
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.7469 12.7455C19.9345 12.558 20.0398 12.3036 20.0398 12.0384C20.0398 11.7732 19.9345 11.5188 19.7469 11.3313L14.1254 5.63909C13.9379 5.45156 13.6835 5.3462 13.4183 5.3462C13.1531 5.3462 12.8987 5.45156 12.7112 5.63909C12.5237 5.82663 12.4183 6.08098 12.4183 6.3462C12.4183 6.61142 12.5237 6.86577 12.7112 7.05331L16.6427 10.9848L4.93303 10.999C4.80102 10.9984 4.67021 11.024 4.54814 11.0743C4.42608 11.1246 4.31517 11.1985 4.22183 11.2918C4.12848 11.3852 4.05454 11.4961 4.00427 11.6182C3.954 11.7402 3.9284 11.871 3.92894 12.0031C3.9284 12.1351 3.954 12.2659 4.00427 12.3879C4.05454 12.51 4.12848 12.6209 4.22183 12.7143C4.31517 12.8076 4.42608 12.8815 4.54814 12.9318C4.67021 12.9821 4.80102 13.0077 4.93303 13.0071L16.6569 13.0071L12.7112 16.9528C12.5237 17.1403 12.4183 17.3947 12.4183 17.6599C12.4183 17.9251 12.5237 18.1795 12.7112 18.367C12.8987 18.5546 13.1531 18.6599 13.4183 18.6599C13.6835 18.6599 13.9379 18.5546 14.1254 18.367L19.7469 12.7455Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -149,7 +149,8 @@ export function CombinedActivity(props: { limit?: number }) {
</For> </For>
</Match> </Match>
</Switch> </Switch>
<Show when={props.limit && activity.latest.length > 0}> {/* Only show on the home screen */}
<Show when={props.limit}>
<A <A
href="/activity" href="/activity"
class="self-center font-semibold text-m-red no-underline active:text-m-red/80" class="self-center font-semibold text-m-red no-underline active:text-m-red/80"

View File

@@ -110,6 +110,8 @@ export function ActivityItem(props: {
const firstContact = () => const firstContact = () =>
props.contacts?.length ? props.contacts[0] : null; props.contacts?.length ? props.contacts[0] : null;
// TODO: pass a value to the timeago function that will cause it to recalculate on sync
return ( return (
<div <div
onClick={() => props.onClick && props.onClick()} onClick={() => props.onClick && props.onClick()}

View File

@@ -11,6 +11,15 @@ import {
} from "~/components/layout"; } from "~/components/layout";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
export function SimpleErrorDisplay(props: { error: Error }) {
return (
<p class="rounded-xl bg-white/10 p-4 font-mono">
<span class="font-bold">{props.error.name}</span>:{" "}
{props.error.message}
</p>
);
}
export function ErrorDisplay(props: { error: Error }) { export function ErrorDisplay(props: { error: Error }) {
const i18n = useI18n(); const i18n = useI18n();
return ( return (
@@ -21,10 +30,7 @@ export function ErrorDisplay(props: { error: Error }) {
<SmallHeader> <SmallHeader>
{i18n.t("error.general.never_should_happen")} {i18n.t("error.general.never_should_happen")}
</SmallHeader> </SmallHeader>
<p class="rounded-xl bg-white/10 p-4 font-mono"> <SimpleErrorDisplay error={props.error} />
<span class="font-bold">{props.error.name}</span>:{" "}
{props.error.message}
</p>
<NiceP> <NiceP>
{i18n.t("error.general.try_reloading")}{" "} {i18n.t("error.general.try_reloading")}{" "}
<ExternalLink href="https://matrix.to/#/#mutiny-community:lightninghackers.com"> <ExternalLink href="https://matrix.to/#/#mutiny-community:lightninghackers.com">

View File

@@ -0,0 +1,164 @@
import {
createEffect,
createResource,
For,
Match,
Show,
Switch
} from "solid-js";
import rightArrow from "~/assets/icons/right-arrow.svg";
import { AmountSats, VStack } from "~/components";
import { useMegaStore } from "~/state/megaStore";
import { fetchZaps, getHexpubFromNpub } from "~/utils";
import { timeAgo } from "~/utils/prettyPrintTime";
function Avatar(props: { image_url?: string }) {
return (
<div class="flex h-[3rem] w-[3rem] flex-none items-center justify-center self-center overflow-clip rounded-full border-b border-t border-b-white/10 border-t-white/50 bg-neutral-700 text-3xl uppercase">
<Switch>
<Match when={props.image_url}>
<img src={props.image_url} alt={"image"} />
</Match>
<Match when={true}>?</Match>
</Switch>
</div>
);
}
function formatProfileLink(hexpub: string): string {
return `https://primal.net/p/${hexpub}`;
}
export function NostrActivity() {
const [state, _actions] = useMegaStore();
const [data, { refetch }] = createResource(state.npub, fetchZaps);
const userHexpub = getHexpubFromNpub(state.npub);
function nameFromHexpub(hexpub: string): string {
const profile = data.latest?.profiles[hexpub];
if (!profile) return hexpub;
const parsed = JSON.parse(profile.content);
const name = parsed.display_name || parsed.name;
return name;
}
function imageFromHexpub(hexpub: string): string | undefined {
const profile = data.latest?.profiles[hexpub];
if (!profile) return;
const parsed = JSON.parse(profile.content);
const image_url = parsed.picture;
return image_url;
}
createEffect(() => {
// Should re-run after every sync
if (!state.is_syncing) {
refetch();
}
});
return (
<VStack>
<For each={data.latest?.zaps}>
{(zap) => (
<div
class="rounded-lg bg-m-grey-800 p-2"
classList={{
"outline outline-m-blue":
userHexpub === zap.to_hexpub
}}
>
<div class="grid grid-cols-[1fr_auto_1fr] gap-4">
<div class="grid gap-2 sm:grid-cols-[auto_1fr] sm:items-center">
<Avatar
image_url={imageFromHexpub(zap.from_hexpub)}
/>
<span class="truncate whitespace-nowrap text-left text-sm font-semibold uppercase">
<Switch>
<Match when={zap.kind === "public"}>
<a
href={formatProfileLink(
zap.from_hexpub
)}
target="_blank"
rel="noopener noreferrer"
class="no-underline"
>
{nameFromHexpub(
zap.from_hexpub
)}
</a>
</Match>
<Match when={zap.kind === "private"}>
Private
</Match>
<Match when={zap.kind === "anonymous"}>
Anonymous
</Match>
</Switch>
</span>
</div>
<div class="flex flex-col items-center justify-center">
<div class="flex items-center gap-1">
<AmountSats amountSats={zap.amount_sats} />
<img
src={rightArrow}
alt="right arrow"
class="h-4 w-4"
/>
</div>
<time class="text-sm text-m-grey-400">
<Show
when={zap.event_id}
fallback={timeAgo(
zap.timestamp,
data.latest?.until
)}
>
<a
href={`https://primal.net/e/${zap.event_id}`}
target="_blank"
rel="noopener noreferrer"
>
{timeAgo(
zap.timestamp,
data.latest?.until
)}
</a>
</Show>
</time>
</div>
<div class="grid gap-2 self-end sm:grid-cols-[1fr_auto] sm:items-center ">
<div class="self-right flex justify-end">
<Avatar
image_url={imageFromHexpub(
zap.to_hexpub
)}
/>
</div>
<a
href={formatProfileLink(zap.to_hexpub)}
class="truncate whitespace-nowrap text-right text-sm font-semibold uppercase no-underline sm:-order-1 sm:text-right"
target="_blank"
rel="noopener noreferrer"
>
{nameFromHexpub(zap.to_hexpub)}
</a>
</div>
</div>
<Show when={zap.content}>
<hr class="my-2 border-m-grey-750" />
<p
class="truncate text-center text-sm font-light text-neutral-200"
textContent={zap.content}
/>
</Show>
</div>
)}
</For>
</VStack>
);
}

View File

@@ -0,0 +1,81 @@
import { createForm, required, SubmitHandler } from "@modular-forms/solid";
import { createSignal, Show } from "solid-js";
import { Button, VStack } from "~/components";
import { InfoBox } from "~/components/InfoBox";
import { TextField } from "~/components/layout/TextField";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import { eify } from "~/utils";
export type NostrContactsForm = {
npub: string;
};
const PRIMAL_API = import.meta.env.VITE_PRIMAL;
export function SyncContactsForm() {
const i18n = useI18n();
const [state, actions] = useMegaStore();
const [error, setError] = createSignal<Error>();
const [feedbackForm, { Form, Field }] = createForm<NostrContactsForm>({
initialValues: {
npub: ""
}
});
const handleSubmit: SubmitHandler<NostrContactsForm> = async (
f: NostrContactsForm
) => {
try {
const npub = f.npub.trim();
if (!PRIMAL_API) throw new Error("PRIMAL_API not set");
await state.mutiny_wallet?.sync_nostr_contacts(PRIMAL_API, 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="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>
);
}

View File

@@ -41,3 +41,5 @@ export * from "./SetupErrorDisplay";
export * from "./ShareCard"; export * from "./ShareCard";
export * from "./TagEditor"; export * from "./TagEditor";
export * from "./Toaster"; export * from "./Toaster";
export * from "./NostrActivity";
export * from "./SyncContactsForm";

View File

@@ -143,6 +143,7 @@ export default {
activity: { activity: {
title: "Activity", title: "Activity",
mutiny: "Mutiny", mutiny: "Mutiny",
wallet: "Wallet",
nostr: "Nostr", nostr: "Nostr",
view_all: "View all", view_all: "View all",
receive_some_sats_to_get_started: "Receive some sats to get started", receive_some_sats_to_get_started: "Receive some sats to get started",
@@ -411,6 +412,12 @@ export default {
"Lightning Service Provider. Automatically opens channels to you for inbound liquidity. Also wraps invoices for privacy.", "Lightning Service Provider. Automatically opens channels to you for inbound liquidity. Also wraps invoices for privacy.",
error_lsp: "That doesn't look like a URL", error_lsp: "That doesn't look like a URL",
save: "Save" save: "Save"
},
nostr_contacts: {
title: "Nostr Contacts",
npub_label: "Nostr npub",
npub_required: "Npub can't be blank",
sync: "Sync"
} }
}, },
swap: { swap: {

View File

@@ -1,10 +1,18 @@
import { Tabs } from "@kobalte/core"; import { Tabs } from "@kobalte/core";
import { Contact } from "@mutinywallet/mutiny-wasm"; import { Contact } from "@mutinywallet/mutiny-wasm";
import { createResource, For, Show, Suspense } from "solid-js"; import {
createEffect,
createResource,
ErrorBoundary,
For,
Match,
Show,
Suspense,
Switch
} from "solid-js";
import { import {
BackLink, BackLink,
Button,
Card, Card,
CombinedActivity, CombinedActivity,
ContactEditor, ContactEditor,
@@ -16,8 +24,11 @@ import {
MutinyWalletGuard, MutinyWalletGuard,
NavBar, NavBar,
NiceP, NiceP,
NostrActivity,
SafeArea, SafeArea,
showToast, showToast,
SimpleErrorDisplay,
SyncContactsForm,
VStack VStack
} from "~/components"; } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
@@ -37,6 +48,13 @@ function ContactRow() {
return []; return [];
} }
}); });
// If the user sets an npub we should refetch the contacts list
createEffect(() => {
if (state.npub) {
refetch();
}
});
const [gradients] = createResource(contacts, gradientsPerContact); const [gradients] = createResource(contacts, gradientsPerContact);
async function createContact(contact: ContactFormValues) { async function createContact(contact: ContactFormValues) {
@@ -92,15 +110,15 @@ export default function Activity() {
<ContactRow /> <ContactRow />
<Tabs.Root defaultValue="mutiny"> <Tabs.Root defaultValue="mutiny">
<Tabs.List class="relative mb-8 mt-4 flex justify-around gap-1 rounded-xl bg-neutral-950 p-1"> <Tabs.List class="relative mb-8 mt-4 flex justify-around gap-1 rounded-xl bg-neutral-950 p-1">
<Tabs.Trigger value="mutiny" class={TAB}> <Tabs.Trigger value="wallet" class={TAB}>
{i18n.t("activity.mutiny")} {i18n.t("activity.wallet")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="nostr" class={TAB}> <Tabs.Trigger value="nostr" class={TAB}>
{i18n.t("activity.nostr")} {i18n.t("activity.nostr")}
</Tabs.Trigger> </Tabs.Trigger>
{/* <Tabs.Indicator class="absolute bg-m-blue transition-all bottom-[-1px] h-[2px]" /> */} {/* <Tabs.Indicator class="absolute bg-m-blue transition-all bottom-[-1px] h-[2px]" /> */}
</Tabs.List> </Tabs.List>
<Tabs.Content value="mutiny"> <Tabs.Content value="wallet">
{/* <MutinyActivity /> */} {/* <MutinyActivity /> */}
<Card title={i18n.t("activity.title")}> <Card title={i18n.t("activity.title")}>
<div class="p-1" /> <div class="p-1" />
@@ -117,16 +135,27 @@ export default function Activity() {
</Card> </Card>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="nostr"> <Tabs.Content value="nostr">
<VStack> <Switch>
<div class="mx-auto my-8 flex max-w-[20rem] flex-col items-center gap-4 text-center"> <Match when={state.npub}>
<NiceP> <ErrorBoundary
{i18n.t("activity.import_contacts")} fallback={(e) => (
</NiceP> <SimpleErrorDisplay error={e} />
<Button disabled intent="blue"> )}
{i18n.t("activity.coming_soon")} >
</Button> <Suspense fallback={<LoadingShimmer />}>
</div> <NostrActivity />
</VStack> </Suspense>
</ErrorBoundary>
</Match>
<Match when={!state.npub}>
<VStack>
<NiceP>
{i18n.t("activity.import_contacts")}
</NiceP>
<SyncContactsForm />
</VStack>
</Match>
</Switch>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
</DefaultMain> </DefaultMain>

View File

@@ -1,31 +1,121 @@
import { TextField } from "@kobalte/core"; import { createForm, required, SubmitHandler } from "@modular-forms/solid";
import { createSignal } from "solid-js"; import { createSignal, Match, Show, Switch } from "solid-js";
import { NavBar } from "~/components";
import { import {
BackLink,
Button, Button,
DefaultMain, DefaultMain,
InnerCard, FancyCard,
InfoBox,
KeyValue,
LargeHeader, LargeHeader,
MiniStringShower,
MutinyWalletGuard, MutinyWalletGuard,
SafeArea NavBar,
} from "~/components/layout"; SafeArea,
import { BackLink } from "~/components/layout/BackLink"; TextField,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { eify } from "~/utils";
type NostrContactsForm = {
npub: string;
};
const PRIMAL_API = import.meta.env.VITE_PRIMAL;
export function SyncContactsForm() {
const i18n = useI18n();
const [state, actions] = useMegaStore();
const [error, setError] = createSignal<Error>();
const [feedbackForm, { Form, Field }] = createForm<NostrContactsForm>({
initialValues: {
npub: ""
}
});
const handleSubmit: SubmitHandler<NostrContactsForm> = async (
f: NostrContactsForm
) => {
try {
const npub = f.npub.trim();
if (!PRIMAL_API) throw new Error("PRIMAL_API not set");
await state.mutiny_wallet?.sync_nostr_contacts(PRIMAL_API, 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="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 default function SyncNostrContacts() { export default function SyncNostrContacts() {
const [state, _] = useMegaStore(); const [state, actions] = useMegaStore();
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<Error>();
const [value, setValue] = createSignal(""); function clearNpub() {
actions.saveNpub("");
}
const onSubmit = async (e: SubmitEvent) => { async function resync() {
e.preventDefault(); setError(undefined);
setLoading(true);
const npub = value().trim(); try {
await state.mutiny_wallet?.sync_nostr_contacts(npub); if (!PRIMAL_API) throw new Error("PRIMAL_API not set");
await state.mutiny_wallet?.sync_nostr_contacts(
setValue(""); PRIMAL_API,
}; // We can only see the resync button if there's an npub set
state.npub!
);
} catch (e) {
console.error(e);
}
setLoading(false);
}
return ( return (
<MutinyWalletGuard> <MutinyWalletGuard>
@@ -33,29 +123,42 @@ export default function SyncNostrContacts() {
<DefaultMain> <DefaultMain>
<BackLink href="/settings" title="Settings" /> <BackLink href="/settings" title="Settings" />
<LargeHeader>Sync Nostr Contacts</LargeHeader> <LargeHeader>Sync Nostr Contacts</LargeHeader>
<InnerCard> <Switch>
<form class="flex flex-col gap-4" onSubmit={onSubmit}> <Match when={state.npub}>
<TextField.Root <VStack>
value={value()} <Show when={error()}>
onChange={setValue} <InfoBox accent="red">
class="flex flex-col gap-4" {error()?.message}
> </InfoBox>
<TextField.Label class="text-sm font-semibold uppercase"> </Show>
Sync Nostr Contacts <FancyCard>
</TextField.Label> <VStack>
<TextField.Input <KeyValue key="Npub">
class="w-full rounded-lg p-2 text-black" <MiniStringShower
// placeholder="LNURL..." text={state.npub || ""}
/> />
<TextField.ErrorMessage class="text-red-500"> </KeyValue>
Doesn't look right... <Button
</TextField.ErrorMessage> intent="blue"
</TextField.Root> onClick={resync}
<Button layout="small" type="submit"> loading={loading()}
Sync >
</Button> Resync
</form> </Button>
</InnerCard> <Button
intent="red"
onClick={clearNpub}
>
Remove
</Button>
</VStack>
</FancyCard>
</VStack>
</Match>
<Match when={!state.npub}>
<SyncContactsForm />
</Match>
</Switch>
</DefaultMain> </DefaultMain>
<NavBar activeTab="settings" /> <NavBar activeTab="settings" />
</SafeArea> </SafeArea>

View File

@@ -52,6 +52,7 @@ export type MegaStore = [
load_stage: LoadStage; load_stage: LoadStage;
settings?: MutinyWalletSettingStrings; settings?: MutinyWalletSettingStrings;
safe_mode?: boolean; safe_mode?: boolean;
npub?: string;
}, },
{ {
setup(password?: string): Promise<void>; setup(password?: string): Promise<void>;
@@ -61,6 +62,7 @@ export type MegaStore = [
setHasBackedUp(): void; setHasBackedUp(): void;
listTags(): Promise<MutinyTagItem[]>; listTags(): Promise<MutinyTagItem[]>;
checkForSubscription(justPaid?: boolean): Promise<void>; checkForSubscription(justPaid?: boolean): Promise<void>;
saveNpub(npub: string): void;
} }
]; ];
@@ -88,7 +90,8 @@ export const Provider: ParentComponent = (props) => {
needs_password: false, needs_password: false,
load_stage: "fresh" as LoadStage, load_stage: "fresh" as LoadStage,
settings: undefined as MutinyWalletSettingStrings | undefined, settings: undefined as MutinyWalletSettingStrings | undefined,
safe_mode: searchParams.safe_mode === "true" safe_mode: searchParams.safe_mode === "true",
npub: localStorage.getItem("npub") || undefined
}); });
const actions = { const actions = {
@@ -235,6 +238,10 @@ export const Provider: ParentComponent = (props) => {
console.error(e); console.error(e);
return []; return [];
} }
},
saveNpub(npub: string) {
localStorage.setItem("npub", npub);
setState({ npub });
} }
}; };

233
src/utils/fetchZaps.ts Normal file
View File

@@ -0,0 +1,233 @@
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { NDKKind, NDKTag, NDKUser } from "@nostr-dev-kit/ndk";
import { ResourceFetcher } from "solid-js";
import { useMegaStore } from "~/state/megaStore";
export type NostrEvent = {
created_at: number;
content: string;
tags: NDKTag[];
kind?: NDKKind | number;
pubkey: string;
id?: string;
sig?: string;
};
export type SimpleZapItem = {
kind: "public" | "private" | "anonymous";
from_hexpub: string;
to_hexpub: string;
timestamp: bigint;
amount_sats: bigint;
note?: string;
event_id?: string;
event: NostrEvent;
content?: string;
};
export type NostrProfile = {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
};
function findByTag(tags: string[][], tag: string): string | undefined {
if (!tags || !Array.isArray(tags)) return;
const found = tags.find((t) => {
if (t[0] === tag) {
return true;
}
});
if (found) {
return found[1];
}
}
async function simpleZapFromEvent(
event: NostrEvent,
wallet: MutinyWallet
): Promise<SimpleZapItem | undefined> {
if (event.kind === 9735 && event.tags?.length > 0) {
const to = findByTag(event.tags, "p") || "";
const request = JSON.parse(
findByTag(event.tags, "description") || "{}"
);
const from = request.pubkey;
const content = request.content;
const anon = findByTag(request.tags, "anon");
const bolt11 = findByTag(event.tags, "bolt11") || "";
if (!bolt11) {
// not a zap!
return undefined;
}
let amount = 0n;
// who is the asshole putting "lnbc9m" in all these tags?
if (bolt11) {
try {
// We hardcode the "bitcoin" network because we don't have a good source of mutinynet zaps
const decoded = await wallet.decode_invoice(bolt11, "bitcoin");
if (decoded.amount_sats) {
amount = decoded.amount_sats;
} else {
console.log("no amount in decoded invoice");
return undefined;
}
} catch (e) {
console.error(e);
return undefined;
}
}
// If we can't get the amount from the invoice we'll fallback to the event tags
if (amount === 0n && request.tags?.length > 0) {
amount = BigInt(findByTag(request.tags, "amount") || "0") / 1000n;
}
return {
// If the anon field is empty it's anon, if it has length it's private, otherwise it's public
kind:
typeof anon === "string"
? anon.length
? "private"
: "anonymous"
: "public",
from_hexpub: from,
to_hexpub: to,
timestamp: BigInt(event.created_at),
amount_sats: amount,
note: request.id,
event_id: findByTag(request.tags, "e"),
event,
content: content
};
}
}
const PRIMAL_API = import.meta.env.VITE_PRIMAL;
export function getHexpubFromNpub(npub?: string) {
if (!npub) return;
const user = new NDKUser({ npub });
return user.hexpubkey();
}
export const fetchZaps: ResourceFetcher<
string,
{
follows: string[];
zaps: SimpleZapItem[];
profiles: Record<string, NostrProfile>;
until?: number;
}
> = async (npub, info) => {
const [state, _actions] = useMegaStore();
console.log("fetching zaps for:", npub);
const follows: string[] = info?.value ? info.value.follows : [];
const zaps: SimpleZapItem[] = [];
const profiles: Record<string, NostrProfile> = info.value?.profiles || {};
let newUntil = undefined;
if (!PRIMAL_API) throw new Error("Missing PRIMAL_API environment variable");
// Only have to ask the relays for follows one time
if (follows.length === 0) {
const pubkey = getHexpubFromNpub(npub);
const response = await fetch(PRIMAL_API, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify([
"contact_list",
{ pubkey: pubkey, extended_response: false }
])
});
if (!response.ok) {
throw new Error(`Failed to load follows`);
}
const data = await response.json();
for (const event of data) {
if (event.kind === 3) {
for (const tag of event.tags) {
if (tag[0] === "p") {
follows.push(tag[1]);
}
}
}
}
}
const query = {
kinds: [9735, 0, 10000113],
limit: 100,
pubkeys: follows
};
const restPayload = JSON.stringify([
"zaps_feed",
// If we have a until value, use it, otherwise don't include it
info?.value?.until ? { ...query, since: info.value?.until } : query
]);
const response = await fetch(PRIMAL_API, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: restPayload
});
if (!response.ok) {
throw new Error(`Failed to load zaps`);
}
const data = await response.json();
for (const object of data) {
if (object.kind === 10000113) {
const content = JSON.parse(object.content);
if (content?.until) {
newUntil = content?.until + 1;
}
}
if (object.kind === 0) {
profiles[object.pubkey] = object;
}
if (object.kind === 9735) {
const event = await simpleZapFromEvent(
object,
state.mutiny_wallet!
);
// Only add it if it's a valid zap (not undefined)
if (event) {
zaps.push(event);
}
}
}
return {
follows,
zaps: [...zaps, ...(info?.value?.zaps || [])],
profiles,
until: newUntil ? newUntil : info?.value?.until
};
};

View File

@@ -14,3 +14,4 @@ export * from "./timeout";
export * from "./typescript"; export * from "./typescript";
export * from "./useCopy"; export * from "./useCopy";
export * from "./words"; export * from "./words";
export * from "./fetchZaps";

View File

@@ -10,7 +10,11 @@ export function prettyPrintTime(ts: number) {
return new Date(ts * 1000).toLocaleString("en-US", options); return new Date(ts * 1000).toLocaleString("en-US", options);
} }
export function timeAgo(ts?: number | bigint): string { // Rerender signal is a silly way to force timeAgo to recalculate even though the timestamp is static
export function timeAgo(
ts?: number | bigint,
_rerenderSignal?: number
): string {
if (!ts || ts === 0) return "Pending"; if (!ts || ts === 0) return "Pending";
const timestamp = Number(ts) * 1000; const timestamp = Number(ts) * 1000;
const now = Date.now(); const now = Date.now();

View File

@@ -60,13 +60,14 @@ export default defineConfig({
"@solid-primitives/upload", "@solid-primitives/upload",
"i18next", "i18next",
"i18next-browser-languagedetector", "i18next-browser-languagedetector",
"@mutinywallet/barcode-scanner",
"@nostr-dev-kit/ndk",
"@capacitor/clipboard", "@capacitor/clipboard",
"@capacitor/core", "@capacitor/core",
"@capacitor/filesystem", "@capacitor/filesystem",
"@capacitor/toast", "@capacitor/toast",
"@mutinywallet/barcode-scanner",
"@capacitor/app", "@capacitor/app",
"@capacitor/browser" "@capacitor/browser",
], ],
// This is necessary because otherwise `vite dev` can't find the wasm // This is necessary because otherwise `vite dev` can't find the wasm
exclude: ["@mutinywallet/mutiny-wasm", "@mutinywallet/waila-wasm"] exclude: ["@mutinywallet/mutiny-wasm", "@mutinywallet/waila-wasm"]