mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-29 03:44:20 +01:00
280 lines
7.7 KiB
TypeScript
280 lines
7.7 KiB
TypeScript
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
|
|
import { ResourceFetcher } from "solid-js";
|
|
|
|
import { useMegaStore } from "~/state/megaStore";
|
|
import { hexpubFromNpub, NostrKind, NostrTag } from "~/utils/nostr";
|
|
|
|
export type NostrEvent = {
|
|
created_at: number;
|
|
content: string;
|
|
tags: NostrTag[];
|
|
kind?: NostrKind | 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;
|
|
|
|
async function fetchFollows(npub: string): Promise<string[]> {
|
|
let pubkey = undefined;
|
|
try {
|
|
pubkey = await hexpubFromNpub(npub);
|
|
} catch (err) {
|
|
console.error("Failed to get hexpub from npub");
|
|
throw err;
|
|
}
|
|
|
|
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();
|
|
|
|
const follows: string[] = [];
|
|
|
|
for (const event of data) {
|
|
if (event.kind === 3) {
|
|
for (const tag of event.tags) {
|
|
if (tag[0] === "p") {
|
|
follows.push(tag[1]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return follows;
|
|
}
|
|
|
|
type PrimalResponse = NostrEvent | NostrProfile;
|
|
|
|
async function fetchZapsFromPrimal(
|
|
follows: string[],
|
|
until?: number
|
|
): Promise<PrimalResponse[]> {
|
|
if (!PRIMAL_API) throw new Error("Missing PRIMAL_API environment variable");
|
|
|
|
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
|
|
until ? { ...query, since: 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();
|
|
|
|
// A primal response could be an error ({error: "error"}, or an array of events
|
|
if (data.error || !Array.isArray(data)) {
|
|
throw new Error("Zap response was not an array");
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
export const fetchZaps: ResourceFetcher<
|
|
string,
|
|
{
|
|
follows: string[];
|
|
zaps: SimpleZapItem[];
|
|
profiles: Record<string, NostrProfile>;
|
|
until?: number;
|
|
}
|
|
> = async (npub, info) => {
|
|
const [state, _actions] = useMegaStore();
|
|
|
|
try {
|
|
console.log("fetching zaps for:", npub);
|
|
let 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) {
|
|
follows = await fetchFollows(npub);
|
|
}
|
|
|
|
// Ask primal for all the zaps for these follow pubkeys
|
|
const data = await fetchZapsFromPrimal(follows, info?.value?.until);
|
|
|
|
// Parse the primal response
|
|
for (const object of data) {
|
|
if (object.kind === 10000113) {
|
|
console.log("got a 10000113 object", object);
|
|
try {
|
|
const content = JSON.parse(object.content);
|
|
if (content?.until) {
|
|
newUntil = content?.until + 1;
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to parse content: ", object.content);
|
|
}
|
|
}
|
|
|
|
if (object.kind === 0) {
|
|
console.log("got a 0 object", object);
|
|
profiles[object.pubkey] = object as NostrProfile;
|
|
}
|
|
|
|
if (object.kind === 9735) {
|
|
console.log("got a 9735 object", object);
|
|
try {
|
|
const event = await simpleZapFromEvent(
|
|
object,
|
|
state.mutiny_wallet!
|
|
);
|
|
|
|
// Only add it if it's a valid zap (not undefined)
|
|
if (event) {
|
|
zaps.push(event);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to parse zap event: ", object);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
follows,
|
|
zaps: [...zaps, ...(info?.value?.zaps || [])],
|
|
profiles,
|
|
until: newUntil ? newUntil : info?.value?.until
|
|
};
|
|
} catch (e) {
|
|
console.error("Failed to load zaps: ", e);
|
|
throw new Error("Failed to load zaps");
|
|
}
|
|
};
|