2023-01-06 14:36:13 +00:00
|
|
|
import * as secp from "@noble/secp256k1";
|
2023-02-07 20:04:50 +00:00
|
|
|
import { sha256 as hash } from "@noble/hashes/sha256";
|
2023-03-02 15:23:53 +00:00
|
|
|
import { bytesToHex } from "@noble/hashes/utils";
|
|
|
|
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
2023-01-06 14:36:13 +00:00
|
|
|
import { bech32 } from "bech32";
|
2023-03-03 19:01:30 +00:00
|
|
|
import base32Decode from "base32-decode";
|
2023-02-11 20:05:46 +00:00
|
|
|
import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr";
|
2023-03-02 15:23:53 +00:00
|
|
|
|
2023-02-20 23:14:15 +00:00
|
|
|
import { MetadataCache } from "State/Users";
|
2023-01-03 16:55:51 +00:00
|
|
|
|
2023-02-03 21:38:14 +00:00
|
|
|
export const sha256 = (str: string) => {
|
2023-02-07 20:04:50 +00:00
|
|
|
return secp.utils.bytesToHex(hash(str));
|
|
|
|
};
|
2023-02-03 21:38:14 +00:00
|
|
|
|
2023-01-16 17:48:25 +00:00
|
|
|
export async function openFile(): Promise<File | undefined> {
|
2023-02-09 12:26:54 +00:00
|
|
|
return new Promise(resolve => {
|
2023-02-07 19:47:57 +00:00
|
|
|
const elm = document.createElement("input");
|
2023-02-07 20:04:50 +00:00
|
|
|
elm.type = "file";
|
|
|
|
elm.onchange = (e: Event) => {
|
2023-02-07 19:47:57 +00:00
|
|
|
const elm = e.target as HTMLInputElement;
|
2023-02-07 20:04:50 +00:00
|
|
|
if (elm.files) {
|
|
|
|
resolve(elm.files[0]);
|
|
|
|
} else {
|
|
|
|
resolve(undefined);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
elm.click();
|
|
|
|
});
|
2023-01-03 16:55:51 +00:00
|
|
|
}
|
2023-01-06 14:36:13 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Parse bech32 ids
|
2023-01-06 14:45:30 +00:00
|
|
|
* https://github.com/nostr-protocol/nips/blob/master/19.md
|
2023-01-15 19:40:47 +00:00
|
|
|
* @param id bech32 id
|
2023-01-06 14:36:13 +00:00
|
|
|
*/
|
2023-01-15 19:40:47 +00:00
|
|
|
export function parseId(id: string) {
|
2023-02-07 20:04:50 +00:00
|
|
|
const hrp = ["note", "npub", "nsec"];
|
|
|
|
try {
|
2023-02-09 12:26:54 +00:00
|
|
|
if (hrp.some(a => id.startsWith(a))) {
|
2023-02-07 20:04:50 +00:00
|
|
|
return bech32ToHex(id);
|
|
|
|
}
|
2023-02-07 19:47:57 +00:00
|
|
|
} catch (e) {
|
|
|
|
// Ignore the error.
|
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
return id;
|
2023-01-06 14:36:13 +00:00
|
|
|
}
|
|
|
|
|
2023-01-15 19:40:47 +00:00
|
|
|
export function bech32ToHex(str: string) {
|
2023-03-08 15:17:51 +00:00
|
|
|
try {
|
|
|
|
const nKey = bech32.decode(str, 1_000);
|
|
|
|
const buff = bech32.fromWords(nKey.words);
|
|
|
|
return secp.utils.bytesToHex(Uint8Array.from(buff));
|
|
|
|
} catch {
|
|
|
|
return "";
|
|
|
|
}
|
2023-01-06 14:36:13 +00:00
|
|
|
}
|
|
|
|
|
2023-01-07 20:54:12 +00:00
|
|
|
/**
|
|
|
|
* Decode bech32 to string UTF-8
|
2023-01-15 19:40:47 +00:00
|
|
|
* @param str bech32 encoded string
|
2023-02-07 20:04:50 +00:00
|
|
|
* @returns
|
2023-01-07 20:54:12 +00:00
|
|
|
*/
|
2023-01-15 19:40:47 +00:00
|
|
|
export function bech32ToText(str: string) {
|
2023-03-08 15:17:51 +00:00
|
|
|
try {
|
|
|
|
const decoded = bech32.decode(str, 1000);
|
|
|
|
const buf = bech32.fromWords(decoded.words);
|
|
|
|
return new TextDecoder().decode(Uint8Array.from(buf));
|
|
|
|
} catch {
|
|
|
|
return "";
|
|
|
|
}
|
2023-01-07 20:54:12 +00:00
|
|
|
}
|
|
|
|
|
2023-01-06 14:36:13 +00:00
|
|
|
/**
|
|
|
|
* Convert hex note id to bech32 link url
|
2023-02-07 20:04:50 +00:00
|
|
|
* @param hex
|
|
|
|
* @returns
|
2023-01-06 14:36:13 +00:00
|
|
|
*/
|
2023-01-15 19:40:47 +00:00
|
|
|
export function eventLink(hex: u256) {
|
2023-01-29 19:44:53 +00:00
|
|
|
return `/e/${hexToBech32(NostrPrefix.Note, hex)}`;
|
2023-01-09 16:18:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Convert hex to bech32
|
|
|
|
*/
|
2023-02-09 12:26:54 +00:00
|
|
|
export function hexToBech32(hrp: string, hex?: string) {
|
2023-02-07 20:04:50 +00:00
|
|
|
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
|
|
|
|
return "";
|
|
|
|
}
|
2023-01-15 19:40:47 +00:00
|
|
|
|
2023-02-07 20:04:50 +00:00
|
|
|
try {
|
2023-01-29 19:44:53 +00:00
|
|
|
if (hrp === NostrPrefix.Note || hrp === NostrPrefix.PrivateKey || hrp === NostrPrefix.PublicKey) {
|
2023-02-14 10:34:43 +00:00
|
|
|
const buf = secp.utils.hexToBytes(hex);
|
2023-01-29 19:44:53 +00:00
|
|
|
return bech32.encode(hrp, bech32.toWords(buf));
|
|
|
|
} else {
|
|
|
|
return encodeTLV(hex, hrp as NostrPrefix);
|
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
} catch (e) {
|
|
|
|
console.warn("Invalid hex", hex, e);
|
|
|
|
return "";
|
|
|
|
}
|
2023-01-06 14:36:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Convert hex pubkey to bech32 link url
|
|
|
|
*/
|
2023-01-15 19:40:47 +00:00
|
|
|
export function profileLink(hex: HexKey) {
|
2023-01-29 19:44:53 +00:00
|
|
|
return `/p/${hexToBech32(NostrPrefix.PublicKey, hex)}`;
|
2023-01-08 00:29:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Reaction types
|
|
|
|
*/
|
|
|
|
export const Reaction = {
|
2023-02-07 20:04:50 +00:00
|
|
|
Positive: "+",
|
|
|
|
Negative: "-",
|
2023-01-08 00:29:59 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return normalized reaction content
|
|
|
|
*/
|
2023-01-15 19:40:47 +00:00
|
|
|
export function normalizeReaction(content: string) {
|
2023-02-07 20:04:50 +00:00
|
|
|
switch (content) {
|
|
|
|
case "-":
|
|
|
|
return Reaction.Negative;
|
|
|
|
case "👎":
|
|
|
|
return Reaction.Negative;
|
2023-02-09 11:32:35 +00:00
|
|
|
default:
|
|
|
|
return Reaction.Positive;
|
2023-02-07 20:04:50 +00:00
|
|
|
}
|
2023-01-09 11:00:23 +00:00
|
|
|
}
|
|
|
|
|
2023-01-17 13:03:15 +00:00
|
|
|
/**
|
|
|
|
* Get reactions to a specific event (#e + kind filter)
|
|
|
|
*/
|
2023-02-09 12:26:54 +00:00
|
|
|
export function getReactions(notes: TaggedRawEvent[], id: u256, kind = EventKind.Reaction) {
|
|
|
|
return notes?.filter(a => a.kind === kind && a.tags.some(a => a[0] === "e" && a[1] === id)) || [];
|
2023-01-17 13:03:15 +00:00
|
|
|
}
|
|
|
|
|
2023-01-09 11:00:23 +00:00
|
|
|
/**
|
|
|
|
* Converts LNURL service to LN Address
|
|
|
|
*/
|
2023-01-15 19:40:47 +00:00
|
|
|
export function extractLnAddress(lnurl: string) {
|
2023-02-07 20:04:50 +00:00
|
|
|
// some clients incorrectly set this to LNURL service, patch this
|
|
|
|
if (lnurl.toLowerCase().startsWith("lnurl")) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const url = bech32ToText(lnurl);
|
2023-02-07 20:04:50 +00:00
|
|
|
if (url.startsWith("http")) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const parsedUri = new URL(url);
|
2023-02-07 20:04:50 +00:00
|
|
|
// is lightning address
|
|
|
|
if (parsedUri.pathname.startsWith("/.well-known/lnurlp/")) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const pathParts = parsedUri.pathname.split("/");
|
|
|
|
const username = pathParts[pathParts.length - 1];
|
2023-02-07 20:04:50 +00:00
|
|
|
return `${username}@${parsedUri.hostname}`;
|
|
|
|
}
|
2023-01-09 11:00:23 +00:00
|
|
|
}
|
2023-02-07 20:04:50 +00:00
|
|
|
}
|
|
|
|
return lnurl;
|
2023-01-14 11:57:36 +00:00
|
|
|
}
|
2023-01-17 21:55:53 +00:00
|
|
|
|
|
|
|
export function unixNow() {
|
2023-02-20 23:14:15 +00:00
|
|
|
return Math.floor(unixNowMs() / 1000);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function unixNowMs() {
|
|
|
|
return new Date().getTime();
|
2023-02-07 20:04:50 +00:00
|
|
|
}
|
2023-01-24 14:09:56 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Simple debounce
|
|
|
|
*/
|
|
|
|
export function debounce(timeout: number, fn: () => void) {
|
2023-02-07 19:47:57 +00:00
|
|
|
const t = setTimeout(fn, timeout);
|
2023-02-07 20:04:50 +00:00
|
|
|
return () => clearTimeout(t);
|
2023-02-03 21:38:14 +00:00
|
|
|
}
|
2023-02-08 05:56:00 +00:00
|
|
|
|
2023-02-08 21:10:26 +00:00
|
|
|
export function dedupeByPubkey(events: TaggedRawEvent[]) {
|
|
|
|
const deduped = events.reduce(
|
|
|
|
({ list, seen }: { list: TaggedRawEvent[]; seen: Set<HexKey> }, ev) => {
|
|
|
|
if (seen.has(ev.pubkey)) {
|
|
|
|
return { list, seen };
|
|
|
|
}
|
|
|
|
seen.add(ev.pubkey);
|
|
|
|
return {
|
|
|
|
seen,
|
2023-02-21 14:35:53 +00:00
|
|
|
list: [...list, ev],
|
|
|
|
};
|
|
|
|
},
|
|
|
|
{ list: [], seen: new Set([]) }
|
|
|
|
);
|
|
|
|
return deduped.list as TaggedRawEvent[];
|
|
|
|
}
|
|
|
|
|
|
|
|
export function dedupeById(events: TaggedRawEvent[]) {
|
|
|
|
const deduped = events.reduce(
|
|
|
|
({ list, seen }: { list: TaggedRawEvent[]; seen: Set<HexKey> }, ev) => {
|
|
|
|
if (seen.has(ev.id)) {
|
|
|
|
return { list, seen };
|
|
|
|
}
|
|
|
|
seen.add(ev.id);
|
|
|
|
return {
|
|
|
|
seen,
|
2023-02-08 21:10:26 +00:00
|
|
|
list: [...list, ev],
|
|
|
|
};
|
|
|
|
},
|
|
|
|
{ list: [], seen: new Set([]) }
|
|
|
|
);
|
|
|
|
return deduped.list as TaggedRawEvent[];
|
|
|
|
}
|
2023-02-07 19:47:57 +00:00
|
|
|
|
|
|
|
export function unwrap<T>(v: T | undefined | null): T {
|
|
|
|
if (v === undefined || v === null) {
|
|
|
|
throw new Error("missing value");
|
|
|
|
}
|
|
|
|
return v;
|
|
|
|
}
|
2023-02-10 19:23:52 +00:00
|
|
|
|
|
|
|
export function randomSample<T>(coll: T[], size: number) {
|
|
|
|
const random = [...coll];
|
|
|
|
return random.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, size);
|
|
|
|
}
|
2023-02-13 15:49:19 +00:00
|
|
|
|
|
|
|
export function getNewest(rawNotes: TaggedRawEvent[]) {
|
|
|
|
const notes = [...rawNotes];
|
|
|
|
notes.sort((a, b) => a.created_at - b.created_at);
|
|
|
|
if (notes.length > 0) {
|
|
|
|
return notes[0];
|
|
|
|
}
|
|
|
|
}
|
2023-02-16 11:42:31 +00:00
|
|
|
|
2023-02-16 14:11:29 +00:00
|
|
|
export function tagFilterOfTextRepost(note: TaggedRawEvent, id?: u256): (tag: string[], i: number) => boolean {
|
2023-02-16 11:42:31 +00:00
|
|
|
return (tag, i) =>
|
|
|
|
tag[0] === "e" && tag[3] === "mention" && note.content === `#[${i}]` && (id ? tag[1] === id : true);
|
|
|
|
}
|
2023-02-20 23:14:15 +00:00
|
|
|
|
|
|
|
export function groupByPubkey(acc: Record<HexKey, MetadataCache>, user: MetadataCache) {
|
|
|
|
return { ...acc, [user.pubkey]: user };
|
|
|
|
}
|
2023-02-27 19:21:38 +00:00
|
|
|
|
|
|
|
export function splitByUrl(str: string) {
|
|
|
|
const urlRegex =
|
|
|
|
/((?:http|ftp|https):\/\/(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~_|]))/i;
|
|
|
|
|
|
|
|
return str.split(urlRegex);
|
|
|
|
}
|
2023-02-28 19:25:10 +00:00
|
|
|
|
|
|
|
export const delay = (t: number) => {
|
|
|
|
return new Promise(resolve => {
|
|
|
|
setTimeout(resolve, t);
|
|
|
|
});
|
|
|
|
};
|
2023-03-02 15:23:53 +00:00
|
|
|
|
2023-03-06 10:18:33 +00:00
|
|
|
export interface InvoiceDetails {
|
|
|
|
amount?: number;
|
|
|
|
expire?: number;
|
|
|
|
timestamp?: number;
|
|
|
|
description?: string;
|
|
|
|
descriptionHash?: string;
|
|
|
|
paymentHash?: string;
|
|
|
|
expired: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function decodeInvoice(pr: string): InvoiceDetails | undefined {
|
2023-03-02 15:23:53 +00:00
|
|
|
try {
|
|
|
|
const parsed = invoiceDecode(pr);
|
|
|
|
|
|
|
|
const amountSection = parsed.sections.find(a => a.name === "amount");
|
2023-03-05 16:58:34 +00:00
|
|
|
const amount = amountSection ? Number(amountSection.value as number | string) : undefined;
|
2023-03-02 15:23:53 +00:00
|
|
|
|
|
|
|
const timestampSection = parsed.sections.find(a => a.name === "timestamp");
|
2023-03-06 10:18:33 +00:00
|
|
|
const timestamp = timestampSection ? Number(timestampSection.value as number | string) : undefined;
|
2023-03-02 15:23:53 +00:00
|
|
|
|
|
|
|
const expirySection = parsed.sections.find(a => a.name === "expiry");
|
2023-03-06 10:18:33 +00:00
|
|
|
const expire = expirySection ? Number(expirySection.value as number | string) : undefined;
|
2023-03-02 15:23:53 +00:00
|
|
|
const descriptionSection = parsed.sections.find(a => a.name === "description")?.value;
|
|
|
|
const descriptionHashSection = parsed.sections.find(a => a.name === "description_hash")?.value;
|
|
|
|
const paymentHashSection = parsed.sections.find(a => a.name === "payment_hash")?.value;
|
|
|
|
const ret = {
|
2023-03-05 16:58:34 +00:00
|
|
|
amount: amount,
|
2023-03-06 10:18:33 +00:00
|
|
|
expire: timestamp && expire ? timestamp + expire : undefined,
|
|
|
|
timestamp: timestamp,
|
2023-03-02 15:23:53 +00:00
|
|
|
description: descriptionSection as string | undefined,
|
|
|
|
descriptionHash: descriptionHashSection ? bytesToHex(descriptionHashSection as Uint8Array) : undefined,
|
|
|
|
paymentHash: paymentHashSection ? bytesToHex(paymentHashSection as Uint8Array) : undefined,
|
|
|
|
expired: false,
|
|
|
|
};
|
|
|
|
if (ret.expire) {
|
|
|
|
ret.expired = ret.expire < new Date().getTime() / 1000;
|
|
|
|
}
|
|
|
|
return ret;
|
|
|
|
} catch (e) {
|
|
|
|
console.error(e);
|
|
|
|
}
|
|
|
|
}
|
2023-03-03 19:01:30 +00:00
|
|
|
|
|
|
|
export interface Magnet {
|
|
|
|
dn?: string | string[];
|
|
|
|
tr?: string | string[];
|
|
|
|
xs?: string | string[];
|
|
|
|
as?: string | string[];
|
|
|
|
ws?: string | string[];
|
|
|
|
kt?: string[];
|
|
|
|
ix?: number | number[];
|
|
|
|
xt?: string | string[];
|
|
|
|
infoHash?: string;
|
|
|
|
raw?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Parse a magnet URI and return an object of keys/values
|
|
|
|
*/
|
|
|
|
export function magnetURIDecode(uri: string): Magnet | undefined {
|
|
|
|
try {
|
|
|
|
const result: Record<string, string | number | number[] | string[] | undefined> = {
|
|
|
|
raw: uri,
|
|
|
|
};
|
|
|
|
|
|
|
|
// Support 'magnet:' and 'stream-magnet:' uris
|
|
|
|
const data = uri.trim().split("magnet:?")[1];
|
|
|
|
|
|
|
|
const params = data && data.length > 0 ? data.split("&") : [];
|
|
|
|
|
|
|
|
params.forEach(param => {
|
|
|
|
const split = param.split("=");
|
|
|
|
const key = split[0];
|
|
|
|
const val = decodeURIComponent(split[1]);
|
|
|
|
|
|
|
|
if (!result[key]) {
|
|
|
|
result[key] = [];
|
|
|
|
}
|
|
|
|
|
|
|
|
switch (key) {
|
|
|
|
case "dn": {
|
|
|
|
(result[key] as string[]).push(val.replace(/\+/g, " "));
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case "kt": {
|
|
|
|
val.split("+").forEach(e => {
|
|
|
|
(result[key] as string[]).push(e);
|
|
|
|
});
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case "ix": {
|
|
|
|
(result[key] as number[]).push(Number(val));
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case "so": {
|
|
|
|
// todo: not implemented yet
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
default: {
|
|
|
|
(result[key] as string[]).push(val);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Convenience properties for parity with `parse-torrent-file` module
|
|
|
|
let m;
|
|
|
|
if (result.xt) {
|
|
|
|
const xts = Array.isArray(result.xt) ? result.xt : [result.xt];
|
|
|
|
xts.forEach(xt => {
|
|
|
|
if (typeof xt === "string") {
|
|
|
|
if ((m = xt.match(/^urn:btih:(.{40})/))) {
|
|
|
|
result.infoHash = [m[1].toLowerCase()];
|
|
|
|
} else if ((m = xt.match(/^urn:btih:(.{32})/))) {
|
|
|
|
const decodedStr = base32Decode(m[1], "RFC4648-HEX");
|
|
|
|
result.infoHash = [bytesToHex(new Uint8Array(decodedStr))];
|
|
|
|
} else if ((m = xt.match(/^urn:btmh:1220(.{64})/))) {
|
|
|
|
result.infoHashV2 = [m[1].toLowerCase()];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
if (result.xs) {
|
|
|
|
const xss = Array.isArray(result.xs) ? result.xs : [result.xs];
|
|
|
|
xss.forEach(xs => {
|
|
|
|
if (typeof xs === "string" && (m = xs.match(/^urn:btpk:(.{64})/))) {
|
|
|
|
if (!result.publicKey) {
|
|
|
|
result.publicKey = [];
|
|
|
|
}
|
|
|
|
(result.publicKey as string[]).push(m[1].toLowerCase());
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const [k, v] of Object.entries(result)) {
|
|
|
|
if (Array.isArray(v)) {
|
|
|
|
if (v.length === 1) {
|
|
|
|
result[k] = v[0];
|
|
|
|
} else if (v.length === 0) {
|
|
|
|
result[k] = undefined;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
} catch (e) {
|
|
|
|
console.warn("Failed to parse magnet link", e);
|
|
|
|
}
|
|
|
|
}
|
2023-03-09 10:13:10 +00:00
|
|
|
|
|
|
|
export function chunks<T>(arr: T[], length: number) {
|
|
|
|
const result = [];
|
|
|
|
let idx = 0;
|
|
|
|
let n = arr.length / length;
|
|
|
|
while (n > 0) {
|
|
|
|
result.push(arr.slice(idx, idx + length));
|
|
|
|
idx += length;
|
|
|
|
n -= 1;
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function findTag(e: TaggedRawEvent, tag: string) {
|
|
|
|
const maybeTag = e.tags.find(evTag => {
|
|
|
|
return evTag[0] === tag;
|
|
|
|
});
|
|
|
|
return maybeTag && maybeTag[1];
|
|
|
|
}
|