mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-19 07:14:22 +01:00
zap feed
This commit is contained in:
@@ -17,3 +17,4 @@ VITE_SUBSCRIPTIONS="https://subscriptions-staging.mutinywallet.com"
|
||||
VITE_STORAGE="https://storage-staging.mutinywallet.com"
|
||||
VITE_FEEDBACK="https://feedback-staging.mutinywallet.com"
|
||||
VITE_SCORER="https://scorer-staging.mutinywallet.com"
|
||||
VITE_PRIMAL="https://primal-cache.mutinywallet.com/api"
|
||||
|
||||
1
.github/workflows/android-build.yml
vendored
1
.github/workflows/android-build.yml
vendored
@@ -74,6 +74,7 @@ jobs:
|
||||
VITE_STORAGE: https://storage-staging.mutinywallet.com
|
||||
VITE_FEEDBACK: https://feedback-staging.mutinywallet.com
|
||||
VITE_SCORER: https://scorer-staging.mutinywallet.com
|
||||
VITE_PRIMAL: https://primal-cache.mutinywallet.com/api
|
||||
run: pnpm build
|
||||
|
||||
- name: Capacitor sync
|
||||
|
||||
1
.github/workflows/android-prod.yml
vendored
1
.github/workflows/android-prod.yml
vendored
@@ -67,6 +67,7 @@ jobs:
|
||||
VITE_STORAGE: https://storage.mutinywallet.com
|
||||
VITE_FEEDBACK: https://feedback.mutinywallet.com
|
||||
VITE_SCORER: https://scorer.mutinywallet.com
|
||||
VITE_PRIMAL: https://primal-cache.mutinywallet.com/api
|
||||
run: pnpm build
|
||||
|
||||
- name: Capacitor sync
|
||||
|
||||
1
.github/workflows/android-staging.yml
vendored
1
.github/workflows/android-staging.yml
vendored
@@ -67,6 +67,7 @@ jobs:
|
||||
VITE_STORAGE: https://storage-staging.mutinywallet.com
|
||||
VITE_FEEDBACK: https://feedback-staging.mutinywallet.com
|
||||
VITE_SCORER: https://scorer-staging.mutinywallet.com
|
||||
VITE_PRIMAL: https://primal-cache.mutinywallet.com/api
|
||||
run: pnpm build
|
||||
|
||||
- name: Capacitor sync
|
||||
|
||||
1
.github/workflows/playwright.yml
vendored
1
.github/workflows/playwright.yml
vendored
@@ -64,6 +64,7 @@ jobs:
|
||||
VITE_STORAGE: https://storage-staging.mutinywallet.com
|
||||
VITE_FEEDBACK: https://feedback-staging.mutinywallet.com
|
||||
VITE_SCORER: https://scorer-staging.mutinywallet.com
|
||||
VITE_PRIMAL: https://primal-cache.mutinywallet.com/api
|
||||
run: pnpm exec playwright test
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
|
||||
@@ -57,7 +57,9 @@
|
||||
"@mutinywallet/mutiny-wasm": "0.4.16",
|
||||
"@mutinywallet/ui": "workspace:*",
|
||||
"@mutinywallet/waila-wasm": "^0.2.1",
|
||||
"@nostr-dev-kit/ndk": "^0.8.11",
|
||||
"@solid-primitives/upload": "^0.0.111",
|
||||
"@solid-primitives/websocket": "^1.1.0",
|
||||
"@thisbeyond/solid-select": "^0.14.0",
|
||||
"i18next": "^22.5.1",
|
||||
"i18next-browser-languagedetector": "^7.1.0",
|
||||
|
||||
@@ -72,3 +72,13 @@ select {
|
||||
strong {
|
||||
@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
832
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
src/assets/icons/right-arrow.svg
Normal file
3
src/assets/icons/right-arrow.svg
Normal 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 |
@@ -149,7 +149,8 @@ export function CombinedActivity(props: { limit?: number }) {
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={props.limit && activity.latest.length > 0}>
|
||||
{/* Only show on the home screen */}
|
||||
<Show when={props.limit}>
|
||||
<A
|
||||
href="/activity"
|
||||
class="self-center font-semibold text-m-red no-underline active:text-m-red/80"
|
||||
|
||||
@@ -110,6 +110,8 @@ export function ActivityItem(props: {
|
||||
const firstContact = () =>
|
||||
props.contacts?.length ? props.contacts[0] : null;
|
||||
|
||||
// TODO: pass a value to the timeago function that will cause it to recalculate on sync
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => props.onClick && props.onClick()}
|
||||
|
||||
@@ -11,6 +11,15 @@ import {
|
||||
} from "~/components/layout";
|
||||
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 }) {
|
||||
const i18n = useI18n();
|
||||
return (
|
||||
@@ -21,10 +30,7 @@ export function ErrorDisplay(props: { error: Error }) {
|
||||
<SmallHeader>
|
||||
{i18n.t("error.general.never_should_happen")}
|
||||
</SmallHeader>
|
||||
<p class="rounded-xl bg-white/10 p-4 font-mono">
|
||||
<span class="font-bold">{props.error.name}</span>:{" "}
|
||||
{props.error.message}
|
||||
</p>
|
||||
<SimpleErrorDisplay error={props.error} />
|
||||
<NiceP>
|
||||
{i18n.t("error.general.try_reloading")}{" "}
|
||||
<ExternalLink href="https://matrix.to/#/#mutiny-community:lightninghackers.com">
|
||||
|
||||
164
src/components/NostrActivity.tsx
Normal file
164
src/components/NostrActivity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/SyncContactsForm.tsx
Normal file
81
src/components/SyncContactsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -41,3 +41,5 @@ export * from "./SetupErrorDisplay";
|
||||
export * from "./ShareCard";
|
||||
export * from "./TagEditor";
|
||||
export * from "./Toaster";
|
||||
export * from "./NostrActivity";
|
||||
export * from "./SyncContactsForm";
|
||||
|
||||
@@ -143,6 +143,7 @@ export default {
|
||||
activity: {
|
||||
title: "Activity",
|
||||
mutiny: "Mutiny",
|
||||
wallet: "Wallet",
|
||||
nostr: "Nostr",
|
||||
view_all: "View all",
|
||||
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.",
|
||||
error_lsp: "That doesn't look like a URL",
|
||||
save: "Save"
|
||||
},
|
||||
nostr_contacts: {
|
||||
title: "Nostr Contacts",
|
||||
npub_label: "Nostr npub",
|
||||
npub_required: "Npub can't be blank",
|
||||
sync: "Sync"
|
||||
}
|
||||
},
|
||||
swap: {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Tabs } from "@kobalte/core";
|
||||
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 {
|
||||
BackLink,
|
||||
Button,
|
||||
Card,
|
||||
CombinedActivity,
|
||||
ContactEditor,
|
||||
@@ -16,8 +24,11 @@ import {
|
||||
MutinyWalletGuard,
|
||||
NavBar,
|
||||
NiceP,
|
||||
NostrActivity,
|
||||
SafeArea,
|
||||
showToast,
|
||||
SimpleErrorDisplay,
|
||||
SyncContactsForm,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
@@ -37,6 +48,13 @@ function ContactRow() {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// If the user sets an npub we should refetch the contacts list
|
||||
createEffect(() => {
|
||||
if (state.npub) {
|
||||
refetch();
|
||||
}
|
||||
});
|
||||
const [gradients] = createResource(contacts, gradientsPerContact);
|
||||
|
||||
async function createContact(contact: ContactFormValues) {
|
||||
@@ -92,15 +110,15 @@ export default function Activity() {
|
||||
<ContactRow />
|
||||
<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.Trigger value="mutiny" class={TAB}>
|
||||
{i18n.t("activity.mutiny")}
|
||||
<Tabs.Trigger value="wallet" class={TAB}>
|
||||
{i18n.t("activity.wallet")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="nostr" class={TAB}>
|
||||
{i18n.t("activity.nostr")}
|
||||
</Tabs.Trigger>
|
||||
{/* <Tabs.Indicator class="absolute bg-m-blue transition-all bottom-[-1px] h-[2px]" /> */}
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="mutiny">
|
||||
<Tabs.Content value="wallet">
|
||||
{/* <MutinyActivity /> */}
|
||||
<Card title={i18n.t("activity.title")}>
|
||||
<div class="p-1" />
|
||||
@@ -117,16 +135,27 @@ export default function Activity() {
|
||||
</Card>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="nostr">
|
||||
<VStack>
|
||||
<div class="mx-auto my-8 flex max-w-[20rem] flex-col items-center gap-4 text-center">
|
||||
<NiceP>
|
||||
{i18n.t("activity.import_contacts")}
|
||||
</NiceP>
|
||||
<Button disabled intent="blue">
|
||||
{i18n.t("activity.coming_soon")}
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
<Switch>
|
||||
<Match when={state.npub}>
|
||||
<ErrorBoundary
|
||||
fallback={(e) => (
|
||||
<SimpleErrorDisplay error={e} />
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<LoadingShimmer />}>
|
||||
<NostrActivity />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Match>
|
||||
<Match when={!state.npub}>
|
||||
<VStack>
|
||||
<NiceP>
|
||||
{i18n.t("activity.import_contacts")}
|
||||
</NiceP>
|
||||
<SyncContactsForm />
|
||||
</VStack>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</DefaultMain>
|
||||
|
||||
@@ -1,31 +1,121 @@
|
||||
import { TextField } from "@kobalte/core";
|
||||
import { createSignal } from "solid-js";
|
||||
import { createForm, required, SubmitHandler } from "@modular-forms/solid";
|
||||
import { createSignal, Match, Show, Switch } from "solid-js";
|
||||
|
||||
import { NavBar } from "~/components";
|
||||
import {
|
||||
BackLink,
|
||||
Button,
|
||||
DefaultMain,
|
||||
InnerCard,
|
||||
FancyCard,
|
||||
InfoBox,
|
||||
KeyValue,
|
||||
LargeHeader,
|
||||
MiniStringShower,
|
||||
MutinyWalletGuard,
|
||||
SafeArea
|
||||
} from "~/components/layout";
|
||||
import { BackLink } from "~/components/layout/BackLink";
|
||||
NavBar,
|
||||
SafeArea,
|
||||
TextField,
|
||||
VStack
|
||||
} from "~/components";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
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() {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
||||
const npub = value().trim();
|
||||
await state.mutiny_wallet?.sync_nostr_contacts(npub);
|
||||
|
||||
setValue("");
|
||||
};
|
||||
async function resync() {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (!PRIMAL_API) throw new Error("PRIMAL_API not set");
|
||||
await state.mutiny_wallet?.sync_nostr_contacts(
|
||||
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 (
|
||||
<MutinyWalletGuard>
|
||||
@@ -33,29 +123,42 @@ export default function SyncNostrContacts() {
|
||||
<DefaultMain>
|
||||
<BackLink href="/settings" title="Settings" />
|
||||
<LargeHeader>Sync Nostr Contacts</LargeHeader>
|
||||
<InnerCard>
|
||||
<form class="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
<TextField.Root
|
||||
value={value()}
|
||||
onChange={setValue}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase">
|
||||
Sync Nostr Contacts
|
||||
</TextField.Label>
|
||||
<TextField.Input
|
||||
class="w-full rounded-lg p-2 text-black"
|
||||
// placeholder="LNURL..."
|
||||
/>
|
||||
<TextField.ErrorMessage class="text-red-500">
|
||||
Doesn't look right...
|
||||
</TextField.ErrorMessage>
|
||||
</TextField.Root>
|
||||
<Button layout="small" type="submit">
|
||||
Sync
|
||||
</Button>
|
||||
</form>
|
||||
</InnerCard>
|
||||
<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()}
|
||||
>
|
||||
Resync
|
||||
</Button>
|
||||
<Button
|
||||
intent="red"
|
||||
onClick={clearNpub}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</VStack>
|
||||
</FancyCard>
|
||||
</VStack>
|
||||
</Match>
|
||||
<Match when={!state.npub}>
|
||||
<SyncContactsForm />
|
||||
</Match>
|
||||
</Switch>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="settings" />
|
||||
</SafeArea>
|
||||
|
||||
@@ -52,6 +52,7 @@ export type MegaStore = [
|
||||
load_stage: LoadStage;
|
||||
settings?: MutinyWalletSettingStrings;
|
||||
safe_mode?: boolean;
|
||||
npub?: string;
|
||||
},
|
||||
{
|
||||
setup(password?: string): Promise<void>;
|
||||
@@ -61,6 +62,7 @@ export type MegaStore = [
|
||||
setHasBackedUp(): void;
|
||||
listTags(): Promise<MutinyTagItem[]>;
|
||||
checkForSubscription(justPaid?: boolean): Promise<void>;
|
||||
saveNpub(npub: string): void;
|
||||
}
|
||||
];
|
||||
|
||||
@@ -88,7 +90,8 @@ export const Provider: ParentComponent = (props) => {
|
||||
needs_password: false,
|
||||
load_stage: "fresh" as LoadStage,
|
||||
settings: undefined as MutinyWalletSettingStrings | undefined,
|
||||
safe_mode: searchParams.safe_mode === "true"
|
||||
safe_mode: searchParams.safe_mode === "true",
|
||||
npub: localStorage.getItem("npub") || undefined
|
||||
});
|
||||
|
||||
const actions = {
|
||||
@@ -235,6 +238,10 @@ export const Provider: ParentComponent = (props) => {
|
||||
console.error(e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
saveNpub(npub: string) {
|
||||
localStorage.setItem("npub", npub);
|
||||
setState({ npub });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
233
src/utils/fetchZaps.ts
Normal file
233
src/utils/fetchZaps.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -14,3 +14,4 @@ export * from "./timeout";
|
||||
export * from "./typescript";
|
||||
export * from "./useCopy";
|
||||
export * from "./words";
|
||||
export * from "./fetchZaps";
|
||||
|
||||
@@ -10,7 +10,11 @@ export function prettyPrintTime(ts: number) {
|
||||
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";
|
||||
const timestamp = Number(ts) * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
@@ -60,13 +60,14 @@ export default defineConfig({
|
||||
"@solid-primitives/upload",
|
||||
"i18next",
|
||||
"i18next-browser-languagedetector",
|
||||
"@mutinywallet/barcode-scanner",
|
||||
"@nostr-dev-kit/ndk",
|
||||
"@capacitor/clipboard",
|
||||
"@capacitor/core",
|
||||
"@capacitor/filesystem",
|
||||
"@capacitor/toast",
|
||||
"@mutinywallet/barcode-scanner",
|
||||
"@capacitor/app",
|
||||
"@capacitor/browser"
|
||||
"@capacitor/browser",
|
||||
],
|
||||
// This is necessary because otherwise `vite dev` can't find the wasm
|
||||
exclude: ["@mutinywallet/mutiny-wasm", "@mutinywallet/waila-wasm"]
|
||||
|
||||
Reference in New Issue
Block a user