use @snort/system cache

This commit is contained in:
2023-06-15 12:03:05 +01:00
parent c2a3a706de
commit fc11381ccd
79 changed files with 679 additions and 524 deletions

View File

@ -8,19 +8,14 @@
"@lightninglabs/lnc-web": "^0.2.3-alpha",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.2.0",
"@protobufjs/base64": "^1.1.2",
"@reduxjs/toolkit": "^1.9.1",
"@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1",
"@szhsin/react-menu": "^3.3.1",
"@void-cat/api": "^1.0.4",
"base32-decode": "^1.0.0",
"bech32": "^2.0.0",
"debug": "^4.3.4",
"dexie": "^3.2.2",
"dexie-react-hooks": "^1.1.1",
"dexie": "^3.2.4",
"dns-over-http-resolver": "^2.1.1",
"events": "^3.3.0",
"light-bolt11-decoder": "^2.1.0",
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.2.0",
@ -32,18 +27,9 @@
"react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4",
"use-long-press": "^2.0.3",
"workbox-background-sync": "^6.4.2",
"workbox-broadcast-update": "^6.4.2",
"workbox-cacheable-response": "^6.4.2",
"workbox-core": "^6.4.2",
"workbox-expiration": "^6.4.2",
"workbox-google-analytics": "^6.4.2",
"workbox-navigation-preload": "^6.4.2",
"workbox-precaching": "^6.4.2",
"workbox-range-requests": "^6.4.2",
"workbox-routing": "^6.4.2",
"workbox-strategies": "^6.4.2",
"workbox-streams": "^6.4.2"
"workbox-strategies": "^6.4.2"
},
"scripts": {
"start": "webpack serve",

View File

@ -1,6 +1,6 @@
import { NostrEvent } from "@snort/system";
import { FeedCache } from "@snort/shared";
import { db } from "Db";
import FeedCache from "./FeedCache";
class DMCache extends FeedCache<NostrEvent> {
constructor() {

View File

@ -1,7 +1,7 @@
import { FeedCache } from "@snort/shared";
import { db, EventInteraction } from "Db";
import { LoginStore } from "Login";
import { sha256 } from "SnortUtils";
import FeedCache from "./FeedCache";
class EventInteractionCache extends FeedCache<EventInteraction> {
constructor() {

View File

@ -1,207 +0,0 @@
import { db } from "Db";
import debug from "debug";
import { Table } from "dexie";
import { unixNowMs, unwrap } from "SnortUtils";
type HookFn = () => void;
interface HookFilter {
key: string;
fn: HookFn;
}
export default abstract class FeedCache<TCached> {
#name: string;
#hooks: Array<HookFilter> = [];
#snapshot: Readonly<Array<TCached>> = [];
#changed = true;
#hits = 0;
#miss = 0;
protected table: Table<TCached>;
protected onTable: Set<string> = new Set();
protected cache: Map<string, TCached> = new Map();
constructor(name: string, table: Table<TCached>) {
this.#name = name;
this.table = table;
setInterval(() => {
debug(this.#name)(
"%d loaded, %d on-disk, %d hooks, %d% hit",
this.cache.size,
this.onTable.size,
this.#hooks.length,
((this.#hits / (this.#hits + this.#miss)) * 100).toFixed(1)
);
}, 30_000);
}
async preload() {
if (db.ready) {
const keys = await this.table.toCollection().primaryKeys();
this.onTable = new Set<string>(keys.map(a => a as string));
}
}
hook(fn: HookFn, key: string | undefined) {
if (!key) {
return () => {
//noop
};
}
this.#hooks.push({
key,
fn,
});
return () => {
const idx = this.#hooks.findIndex(a => a.fn === fn);
if (idx >= 0) {
this.#hooks.splice(idx, 1);
}
};
}
getFromCache(key?: string) {
if (key) {
const ret = this.cache.get(key);
if (ret) {
this.#hits++;
} else {
this.#miss++;
}
return ret;
}
}
async get(key?: string) {
if (key && !this.cache.has(key) && db.ready) {
const cached = await this.table.get(key);
if (cached) {
this.cache.set(this.key(cached), cached);
this.notifyChange([key]);
return cached;
}
}
return key ? this.cache.get(key) : undefined;
}
async bulkGet(keys: Array<string>) {
const missing = keys.filter(a => !this.cache.has(a));
if (missing.length > 0 && db.ready) {
const cached = await this.table.bulkGet(missing);
cached.forEach(a => {
if (a) {
this.cache.set(this.key(a), a);
}
});
}
return keys
.map(a => this.cache.get(a))
.filter(a => a)
.map(a => unwrap(a));
}
async set(obj: TCached) {
const k = this.key(obj);
this.cache.set(k, obj);
if (db.ready) {
await this.table.put(obj);
this.onTable.add(k);
}
this.notifyChange([k]);
}
async bulkSet(obj: Array<TCached>) {
if (db.ready) {
await this.table.bulkPut(obj);
obj.forEach(a => this.onTable.add(this.key(a)));
}
obj.forEach(v => this.cache.set(this.key(v), v));
this.notifyChange(obj.map(a => this.key(a)));
}
/**
* Try to update an entry where created values exists
* @param m Profile metadata
* @returns
*/
async update<TCachedWithCreated extends TCached & { created: number; loaded: number }>(m: TCachedWithCreated) {
const k = this.key(m);
const existing = this.getFromCache(k) as TCachedWithCreated;
const updateType = (() => {
if (!existing) {
return "new";
}
if (existing.created < m.created) {
return "updated";
}
if (existing && existing.loaded < m.loaded) {
return "refresh";
}
return "no_change";
})();
debug(this.#name)("Updating %s %s %o", k, updateType, m);
if (updateType !== "no_change") {
const updated = {
...existing,
...m,
};
await this.set(updated);
}
return updateType;
}
/**
* Loads a list of rows from disk cache
* @param keys List of ids to load
* @returns Keys that do not exist on disk cache
*/
async buffer(keys: Array<string>): Promise<Array<string>> {
const needsBuffer = keys.filter(a => !this.cache.has(a));
if (db.ready && needsBuffer.length > 0) {
const mapped = needsBuffer.map(a => ({
has: this.onTable.has(a),
key: a,
}));
const start = unixNowMs();
const fromCache = await this.table.bulkGet(mapped.filter(a => a.has).map(a => a.key));
const fromCacheFiltered = fromCache.filter(a => a !== undefined).map(a => unwrap(a));
fromCacheFiltered.forEach(a => {
this.cache.set(this.key(a), a);
});
this.notifyChange(fromCacheFiltered.map(a => this.key(a)));
debug(this.#name)(
`Loaded %d/%d in %d ms`,
fromCacheFiltered.length,
keys.length,
(unixNowMs() - start).toLocaleString()
);
return mapped.filter(a => !a.has).map(a => a.key);
}
// no IndexdDB always return all keys
return needsBuffer;
}
async clear() {
await this.table.clear();
this.cache.clear();
this.onTable.clear();
}
snapshot() {
if (this.#changed) {
this.#snapshot = this.takeSnapshot();
this.#changed = false;
}
return this.#snapshot;
}
protected notifyChange(keys: Array<string>) {
this.#changed = true;
this.#hooks.filter(a => keys.includes(a.key) || a.key === "*").forEach(h => h.fn());
}
abstract key(of: TCached): string;
abstract takeSnapshot(): Array<TCached>;
}

View File

@ -1,5 +1,5 @@
import { Payment, db } from "Db";
import FeedCache from "./FeedCache";
import { FeedCache } from "@snort/shared";
class Payments extends FeedCache<Payment> {
constructor() {

View File

@ -1,153 +0,0 @@
import FeedCache from "Cache/FeedCache";
import { db } from "Db";
import { MetadataCache } from "@snort/system";
import { LNURL } from "LNURL";
import { fetchNip05Pubkey } from "Nip05/Verifier";
class UserProfileCache extends FeedCache<MetadataCache> {
#zapperQueue: Array<{ pubkey: string; lnurl: string }> = [];
#nip5Queue: Array<{ pubkey: string; nip05: string }> = [];
constructor() {
super("UserCache", db.users);
this.#processZapperQueue();
this.#processNip5Queue();
}
key(of: MetadataCache): string {
return of.pubkey;
}
override async preload(follows?: Array<string>): Promise<void> {
await super.preload();
// load follows profiles
if (follows) {
await this.buffer(follows);
}
}
async search(q: string): Promise<Array<MetadataCache>> {
if (db.ready) {
// on-disk cache will always have more data
return (
await db.users
.where("npub")
.startsWithIgnoreCase(q)
.or("name")
.startsWithIgnoreCase(q)
.or("display_name")
.startsWithIgnoreCase(q)
.or("nip05")
.startsWithIgnoreCase(q)
.toArray()
).slice(0, 5);
} else {
return [...this.cache.values()]
.filter(user => {
const profile = user as MetadataCache;
return (
profile.name?.includes(q) ||
profile.npub?.includes(q) ||
profile.display_name?.includes(q) ||
profile.nip05?.includes(q)
);
})
.slice(0, 5);
}
}
/**
* Try to update the profile metadata cache with a new version
* @param m Profile metadata
* @returns
*/
override async update(m: MetadataCache) {
const updateType = await super.update(m);
if (updateType !== "refresh") {
const lnurl = m.lud16 ?? m.lud06;
if (lnurl) {
this.#zapperQueue.push({
pubkey: m.pubkey,
lnurl,
});
}
if (m.nip05) {
this.#nip5Queue.push({
pubkey: m.pubkey,
nip05: m.nip05,
});
}
}
return updateType;
}
takeSnapshot(): MetadataCache[] {
return [];
}
async #processZapperQueue() {
await this.#batchQueue(
this.#zapperQueue,
async i => {
const svc = new LNURL(i.lnurl);
await svc.load();
const p = this.getFromCache(i.pubkey);
if (p) {
await this.set({
...p,
zapService: svc.zapperPubkey,
});
}
},
5
);
setTimeout(() => this.#processZapperQueue(), 1_000);
}
async #processNip5Queue() {
await this.#batchQueue(
this.#nip5Queue,
async i => {
const [name, domain] = i.nip05.split("@");
const nip5pk = await fetchNip05Pubkey(name, domain);
const p = this.getFromCache(i.pubkey);
if (p) {
await this.set({
...p,
isNostrAddressValid: i.pubkey === nip5pk,
});
}
},
5
);
setTimeout(() => this.#processNip5Queue(), 1_000);
}
async #batchQueue<T>(queue: Array<T>, proc: (v: T) => Promise<void>, batchSize = 3) {
const batch = [];
while (queue.length > 0) {
const i = queue.shift();
if (i) {
batch.push(
(async () => {
try {
await proc(i);
} catch {
console.warn("Failed to process item", i);
}
batch.pop(); // pop any
})()
);
if (batch.length === batchSize) {
await Promise.all(batch);
}
} else {
await Promise.all(batch);
}
}
}
}
export const UserCache = new UserProfileCache();

View File

@ -1,31 +0,0 @@
import { db, UsersRelays } from "Db";
import FeedCache from "./FeedCache";
export class UsersRelaysCache extends FeedCache<UsersRelays> {
constructor() {
super("UserRelays", db.userRelays);
}
key(of: UsersRelays): string {
return of.pubkey;
}
override async preload(follows?: Array<string>): Promise<void> {
await super.preload();
if (follows) {
await this.buffer(follows);
}
}
newest(): number {
let ret = 0;
this.cache.forEach(v => (ret = v.created_at > ret ? v.created_at : ret));
return ret;
}
takeSnapshot(): Array<UsersRelays> {
return [...this.cache.values()];
}
}
export const UserRelays = new UsersRelaysCache();

View File

@ -1,7 +1,10 @@
import { UserProfileCache, UserRelaysCache } from "@snort/system";
import { DmCache } from "./DMCache";
import { InteractionCache } from "./EventInteractionCache";
import { UserCache } from "./UserCache";
import { UserRelays } from "./UserRelayCache";
export const UserCache = new UserProfileCache();
export const UserRelays = new UserRelaysCache();
export { DmCache };
export async function preload(follows?: Array<string>) {
const preloads = [
@ -12,5 +15,3 @@ export async function preload(follows?: Array<string>) {
];
await Promise.all(preloads);
}
export { UserCache, DmCache };

View File

@ -1,8 +1,8 @@
import Dexie, { Table } from "dexie";
import { FullRelaySettings, HexKey, NostrEvent, u256, MetadataCache } from "@snort/system";
import { HexKey, NostrEvent, u256 } from "@snort/system";
export const NAME = "snortDB";
export const VERSION = 9;
export const VERSION = 10;
export interface SubCache {
id: string;
@ -11,19 +11,6 @@ export interface SubCache {
since?: number;
}
export interface RelayMetrics {
addr: string;
events: number;
disconnects: number;
latency: number[];
}
export interface UsersRelays {
pubkey: HexKey;
created_at: number;
relays: FullRelaySettings[];
}
export interface EventInteraction {
id: u256;
event: u256;
@ -41,10 +28,6 @@ export interface Payment {
}
const STORES = {
users: "++pubkey, name, display_name, picture, nip05, npub",
relays: "++addr",
userRelays: "++pubkey",
events: "++id, pubkey, created_at",
dms: "++id, pubkey",
eventInteraction: "++id",
payments: "++url",
@ -52,10 +35,6 @@ const STORES = {
export class SnortDB extends Dexie {
ready = false;
users!: Table<MetadataCache>;
relayMetrics!: Table<RelayMetrics>;
userRelays!: Table<UsersRelays>;
events!: Table<NostrEvent>;
dms!: Table<NostrEvent>;
eventInteraction!: Table<EventInteraction>;
payments!: Table<Payment>;

View File

@ -4,7 +4,7 @@ import { HexKey, TaggedRawEvent } from "@snort/system";
import Note from "Element/Note";
import useLogin from "Hooks/useLogin";
import { UserCache } from "Cache/UserCache";
import { UserCache } from "Cache";
import messages from "./messages";

View File

@ -6,6 +6,7 @@ import useEventPublisher from "Feed/EventPublisher";
import { parseId } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import AsyncButton from "Element/AsyncButton";
import { System } from "index";
import messages from "./messages";
@ -23,7 +24,7 @@ export default function FollowButton(props: FollowButtonProps) {
async function follow(pubkey: HexKey) {
if (publisher) {
const ev = await publisher.contactList([pubkey, ...follows.item], relays.item);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
}
}
@ -33,7 +34,7 @@ export default function FollowButton(props: FollowButtonProps) {
follows.item.filter(a => a !== pubkey),
relays.item
);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
}
}

View File

@ -5,6 +5,7 @@ import { HexKey } from "@snort/system";
import useEventPublisher from "Feed/EventPublisher";
import ProfilePreview from "Element/ProfilePreview";
import useLogin from "Hooks/useLogin";
import { System } from "index";
import messages from "./messages";
@ -31,7 +32,7 @@ export default function FollowListBase({
async function followAll() {
if (publisher) {
const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
}
}

View File

@ -25,6 +25,7 @@ import SnortServiceProvider from "Nip05/SnortServiceProvider";
import { UserCache } from "Cache";
import messages from "./messages";
import { System } from "index";
type Nip05ServiceProps = {
name: string;
@ -215,7 +216,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
nip05,
} as UserMetadata;
const ev = await publisher.metadata(newProfile);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
if (props.onSuccess) {
props.onSuccess(nip05);
}

View File

@ -5,6 +5,7 @@ import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
import { TaggedRawEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt } from "@snort/system";
import { System } from "index";
import useEventPublisher from "Feed/EventPublisher";
import Icon from "Icons/Icon";
import { parseZap } from "Element/Zap";
@ -24,7 +25,7 @@ import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import Reveal from "Element/Reveal";
import useModeration from "Hooks/useModeration";
import { UserCache } from "Cache/UserCache";
import { UserCache } from "Cache";
import Poll from "Element/Poll";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
@ -151,7 +152,7 @@ export default function Note(props: NoteProps) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.item.filter(e => e !== id);
const ev = await publisher.noteList(es, Lists.Pinned);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
@ -162,7 +163,7 @@ export default function Note(props: NoteProps) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.item.filter(e => e !== id);
const ev = await publisher.noteList(es, Lists.Bookmarked);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}

View File

@ -2,6 +2,7 @@ import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { encodeTLV, EventKind, NostrPrefix, TaggedRawEvent, EventBuilder } from "@snort/system";
import { LNURL } from "@snort/shared";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
@ -26,7 +27,6 @@ import {
setOtherEvents,
} from "State/NoteCreator";
import type { RootState } from "State/Store";
import { LNURL } from "LNURL";
import messages from "./messages";
import { ClipboardEventHandler, useState } from "react";
@ -35,6 +35,7 @@ import { Menu, MenuItem } from "@szhsin/react-menu";
import { LoginStore } from "Login";
import { getCurrentSubscription } from "Subscription";
import useLogin from "Hooks/useLogin";
import { System } from "index";
interface NotePreviewProps {
note: TaggedRawEvent;
@ -111,12 +112,12 @@ export function NoteCreator() {
return eb;
};
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
if (selectedCustomRelays) publisher.broadcastAll(ev, selectedCustomRelays);
else publisher.broadcast(ev);
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev));
else System.BroadcastEvent(ev);
dispatch(reset());
for (const oe of otherEvents) {
if (selectedCustomRelays) publisher.broadcastAll(oe, selectedCustomRelays);
else publisher.broadcast(oe);
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, oe));
else System.BroadcastEvent(oe);
}
dispatch(reset());
}

View File

@ -4,6 +4,7 @@ import { useIntl, FormattedMessage } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useLongPress } from "use-long-press";
import { TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists } from "@snort/system";
import { LNURL } from "@snort/shared";
import Icon from "Icons/Icon";
import Spinner from "Icons/Spinner";
@ -26,12 +27,12 @@ import {
} from "State/ReBroadcast";
import useModeration from "Hooks/useModeration";
import { TranslateHost } from "Const";
import { LNURL } from "LNURL";
import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
import { useInteractionCache } from "Hooks/useInteractionCache";
import { ZapPoolController } from "ZapPoolController";
import { System } from "index";
import messages from "./messages";
@ -117,7 +118,7 @@ export default function NoteFooter(props: NoteFooterProps) {
async function react(content: string) {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
publisher.broadcast(evLike);
System.BroadcastEvent(evLike);
await interactionCache.react();
}
}
@ -125,7 +126,7 @@ export default function NoteFooter(props: NoteFooterProps) {
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
const evDelete = await publisher.delete(ev.id);
publisher.broadcast(evDelete);
System.BroadcastEvent(evDelete);
}
}
@ -133,7 +134,7 @@ export default function NoteFooter(props: NoteFooterProps) {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
publisher.broadcast(evRepost);
System.BroadcastEvent(evRepost);
await interactionCache.repost();
}
}
@ -292,7 +293,7 @@ export default function NoteFooter(props: NoteFooterProps) {
if (publisher) {
const es = [...pinned.item, id];
const ev = await publisher.noteList(es, Lists.Pinned);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
@ -301,7 +302,7 @@ export default function NoteFooter(props: NoteFooterProps) {
if (publisher) {
const es = [...bookmarked.item, id];
const ev = await publisher.noteList(es, Lists.Bookmarked);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}

View File

@ -1,4 +1,5 @@
import { TaggedRawEvent } from "@snort/system";
import { LNURL } from "@snort/shared";
import { useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
@ -7,7 +8,6 @@ import Text from "Element/Text";
import useEventPublisher from "Feed/EventPublisher";
import { useWallet } from "Wallet";
import { useUserProfile } from "Hooks/useUserProfile";
import { LNURL } from "LNURL";
import { unwrap } from "SnortUtils";
import { formatShort } from "Number";
import Spinner from "Icons/Spinner";

View File

@ -1,5 +1,6 @@
import { NostrEvent } from "@snort/system";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { LNURL } from "@snort/shared";
import { dedupe, hexToBech32, unixNow } from "SnortUtils";
import FollowListBase from "Element/FollowListBase";
@ -9,7 +10,6 @@ import { Toastore } from "Toaster";
import { getDisplayName } from "Element/ProfileImage";
import { UserCache } from "Cache";
import useLogin from "Hooks/useLogin";
import { LNURL } from "LNURL";
import useEventPublisher from "Feed/EventPublisher";
import { WalletInvoiceState } from "Wallet";

View File

@ -6,6 +6,7 @@ import type { RootState } from "State/Store";
import { setShow, reset, setSelectedCustomRelays } from "State/ReBroadcast";
import messages from "./messages";
import useLogin from "Hooks/useLogin";
import { System } from "index";
export function ReBroadcaster() {
const publisher = useEventPublisher();
@ -14,8 +15,8 @@ export function ReBroadcaster() {
async function sendReBroadcast() {
if (note && publisher) {
if (selectedCustomRelays) publisher.broadcastAll(note, selectedCustomRelays);
else publisher.broadcast(note);
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, note));
else System.BroadcastEvent(note);
dispatch(reset());
}
}

View File

@ -3,6 +3,8 @@ import React, { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { HexKey, NostrEvent, EventPublisher } from "@snort/system";
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "@snort/shared";
import { System } from "index";
import { formatShort } from "Number";
import Icon from "Icons/Icon";
@ -11,7 +13,6 @@ import ProfileImage from "Element/ProfileImage";
import Modal from "Element/Modal";
import QrCode from "Element/QrCode";
import Copy from "Element/Copy";
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
import { chunks, debounce } from "SnortUtils";
import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
@ -133,7 +134,7 @@ export default function SendSats(props: SendSatsProps) {
const randomKey = generateRandomKey();
console.debug("Generated new key for zap: ", randomKey);
const publisher = new EventPublisher(System, randomKey.publicKey, randomKey.privateKey);
const publisher = new EventPublisher(randomKey.publicKey, randomKey.privateKey);
zap = await publisher.zap(amount * 1000, author, relays, note, comment, eb => eb.tag(["anon", ""]));
} else {
zap = await publisher.zap(amount * 1000, author, relays, note, comment);

View File

@ -9,7 +9,7 @@ import { NostrPrefix, MetadataCache } from "@snort/system";
import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05";
import { hexToBech32 } from "SnortUtils";
import { UserCache } from "Cache/UserCache";
import { UserCache } from "Cache";
import messages from "./messages";

View File

@ -6,6 +6,7 @@ import { useState } from "react";
import useFileUpload from "Upload";
import { openFile } from "SnortUtils";
import Textarea from "./Textarea";
import { System } from "index";
export default function WriteDm({ chatPubKey }: { chatPubKey: string }) {
const [msg, setMsg] = useState("");
@ -57,7 +58,7 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) {
if (msg && publisher) {
setSending(true);
const ev = await publisher.sendDm(msg, chatPubKey);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
setMsg("");
setSending(false);
}

View File

@ -8,7 +8,7 @@ import { formatShort } from "Number";
import Text from "Element/Text";
import ProfileImage from "Element/ProfileImage";
import { findTag } from "SnortUtils";
import { UserCache } from "Cache/UserCache";
import { UserCache } from "Cache";
import useLogin from "Hooks/useLogin";
import messages from "./messages";

View File

@ -1,41 +0,0 @@
type HookFn<TSnapshot> = (e?: TSnapshot) => void;
interface HookFilter<TSnapshot> {
fn: HookFn<TSnapshot>;
}
/**
* Simple React hookable store with manual change notifications
*/
export default abstract class ExternalStore<TSnapshot> {
#hooks: Array<HookFilter<TSnapshot>> = [];
#snapshot: Readonly<TSnapshot> = {} as Readonly<TSnapshot>;
#changed = true;
hook(fn: HookFn<TSnapshot>) {
this.#hooks.push({
fn,
});
return () => {
const idx = this.#hooks.findIndex(a => a.fn === fn);
if (idx >= 0) {
this.#hooks.splice(idx, 1);
}
};
}
snapshot() {
if (this.#changed) {
this.#snapshot = this.takeSnapshot();
this.#changed = false;
}
return this.#snapshot;
}
protected notifyChange(sn?: TSnapshot) {
this.#changed = true;
this.#hooks.forEach(h => h.fn(sn));
}
abstract takeSnapshot(): TSnapshot;
}

View File

@ -1,13 +1,12 @@
import { useMemo } from "react";
import useLogin from "Hooks/useLogin";
import { EventPublisher } from "@snort/system";
import { System } from "index";
export default function useEventPublisher() {
const { publicKey, privateKey } = useLogin();
return useMemo(() => {
if (publicKey) {
return new EventPublisher(System, publicKey, privateKey);
return new EventPublisher(publicKey, privateKey);
}
}, [publicKey, privateKey]);
}

View File

@ -14,7 +14,7 @@ import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPi
import { SnortPubKey } from "Const";
import { SubscriptionEvent } from "Subscription";
import useRelaysFeedFollows from "./RelaysFeedFollows";
import { UserRelays } from "Cache/UserRelayCache";
import { UserRelays } from "Cache";
/**
* Managed loading data for the current logged in user

View File

@ -12,7 +12,7 @@ import debug from "debug";
import { sanitizeRelayUrl } from "SnortUtils";
import useRequestBuilder from "Hooks/useRequestBuilder";
import { UserRelays } from "Cache/UserRelayCache";
import { UserRelays } from "Cache";
interface RelayList {
pubkey: string;

View File

@ -1,5 +1,5 @@
import * as utils from "@noble/curves/abstract/utils";
import * as base64 from "@protobufjs/base64";
import { base64 } from "@scure/base";
import { hmacSha256, unwrap } from "SnortUtils";
import useLogin from "Hooks/useLogin";
@ -23,7 +23,7 @@ export default function useImgProxy() {
utils.hexToBytes(unwrap(settings).salt),
te.encode(u)
);
return urlSafe(base64.encode(result, 0, result.byteLength));
return urlSafe(base64.encode(result));
}
return {
@ -32,7 +32,7 @@ export default function useImgProxy() {
if (url.startsWith("data:") || url.startsWith("blob:")) return url;
const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : "";
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes, 0, urlBytes.byteLength));
const urlEncoded = urlSafe(base64.encode(urlBytes));
const path = `/${opt}/${urlEncoded}`;
const sig = signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;

View File

@ -3,6 +3,7 @@ import useEventPublisher from "Feed/EventPublisher";
import useLogin from "Hooks/useLogin";
import { setBlocked, setMuted } from "Login";
import { appendDedupe } from "SnortUtils";
import { System } from "index";
export default function useModeration() {
const login = useLogin();
@ -12,7 +13,7 @@ export default function useModeration() {
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
if (publisher) {
const ev = await publisher.muted(pub, priv);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
return ev.created_at * 1000;
}
return 0;

View File

@ -1,8 +1,8 @@
import { useEffect, useSyncExternalStore } from "react";
import { HexKey, MetadataCache } from "@snort/system";
import { UserCache } from "Cache/UserCache";
import { ProfileLoader } from "index";
import { UserCache } from "Cache";
export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
const user = useSyncExternalStore<MetadataCache | undefined>(

View File

@ -1,231 +0,0 @@
import { HexKey, NostrEvent } from "@snort/system";
import { EmailRegex } from "Const";
import { bech32ToText, unwrap } from "SnortUtils";
const PayServiceTag = "payRequest";
export enum LNURLErrorCode {
ServiceUnavailable = 1,
InvalidLNURL = 2,
}
export class LNURLError extends Error {
code: LNURLErrorCode;
constructor(code: LNURLErrorCode, msg: string) {
super(msg);
this.code = code;
}
}
export class LNURL {
#url: URL;
#service?: LNURLService;
/**
* Setup LNURL service
* @param lnurl bech32 lnurl / lightning address / https url
*/
constructor(lnurl: string) {
lnurl = lnurl.toLowerCase().trim();
if (lnurl.startsWith("lnurl")) {
const decoded = bech32ToText(lnurl);
if (!decoded.startsWith("http")) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Not a url");
}
this.#url = new URL(decoded);
} else if (lnurl.match(EmailRegex)) {
const [handle, domain] = lnurl.split("@");
this.#url = new URL(`https://${domain}/.well-known/lnurlp/${handle}`);
} else if (lnurl.startsWith("https:")) {
this.#url = new URL(lnurl);
} else if (lnurl.startsWith("lnurlp:")) {
const tmp = new URL(lnurl);
tmp.protocol = "https:";
this.#url = tmp;
} else {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Could not determine service url");
}
}
/**
* URL of this payService
*/
get url() {
return this.#url;
}
/**
* Return the optimal formatted LNURL
*/
get lnurl() {
if (this.isLNAddress) {
return this.getLNAddress();
}
return this.#url.toString();
}
/**
* Human readable name for this service
*/
get name() {
// LN Address formatted URL
if (this.isLNAddress) {
return this.getLNAddress();
}
// Generic LUD-06 url
return this.#url.hostname;
}
/**
* Is this LNURL a LUD-16 Lightning Address
*/
get isLNAddress() {
return this.#url.pathname.startsWith("/.well-known/lnurlp/");
}
/**
* Get the LN Address for this LNURL
*/
getLNAddress() {
const pathParts = this.#url.pathname.split("/");
const username = pathParts[pathParts.length - 1];
return `${username}@${this.#url.hostname}`;
}
/**
* Create a NIP-57 zap tag from this LNURL
*/
getZapTag() {
if (this.isLNAddress) {
return ["zap", this.getLNAddress(), "lud16"];
} else {
return ["zap", this.#url.toString(), "lud06"];
}
}
async load() {
const rsp = await fetch(this.#url);
if (rsp.ok) {
this.#service = await rsp.json();
this.#validateService();
}
}
/**
* Fetch an invoice from the LNURL service
* @param amount Amount in sats
* @param comment
* @param zap
* @returns
*/
async getInvoice(amount: number, comment?: string, zap?: NostrEvent) {
const callback = new URL(unwrap(this.#service?.callback));
const query = new Map<string, string>();
if (callback.search.length > 0) {
callback.search
.slice(1)
.split("&")
.forEach(a => {
const pSplit = a.split("=");
query.set(pSplit[0], pSplit[1]);
});
}
query.set("amount", Math.floor(amount * 1000).toString());
if (comment && this.#service?.commentAllowed) {
query.set("comment", comment);
}
if (this.#service?.nostrPubkey && zap) {
query.set("nostr", JSON.stringify(zap));
}
const baseUrl = `${callback.protocol}//${callback.host}${callback.pathname}`;
const queryJoined = [...query.entries()].map(v => `${v[0]}=${encodeURIComponent(v[1])}`).join("&");
try {
const rsp = await fetch(`${baseUrl}?${queryJoined}`);
if (rsp.ok) {
const data: LNURLInvoice = await rsp.json();
console.debug("[LNURL]: ", data);
if (data.status === "ERROR") {
throw new Error(data.reason);
} else {
return data;
}
} else {
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, `Failed to fetch invoice (${rsp.statusText})`);
}
} catch (e) {
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, "Failed to load callback");
}
}
/**
* Are zaps (NIP-57) supported
*/
get canZap() {
return this.#service?.nostrPubkey ? true : false;
}
/**
* Return pubkey of zap service
*/
get zapperPubkey() {
return this.#service?.nostrPubkey;
}
/**
* Get the max allowed comment length
*/
get maxCommentLength() {
return this.#service?.commentAllowed ?? 0;
}
/**
* Min sendable in milli-sats
*/
get min() {
return this.#service?.minSendable ?? 1_000; // 1 sat
}
/**
* Max sendable in milli-sats
*/
get max() {
return this.#service?.maxSendable ?? 100e9; // 1 BTC in milli-sats
}
#validateService() {
if (this.#service?.tag !== PayServiceTag) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Only LNURLp is supported");
}
if (!this.#service?.callback) {
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "No callback url");
}
}
}
export interface LNURLService {
tag: string;
nostrPubkey?: HexKey;
minSendable?: number;
maxSendable?: number;
metadata: string;
callback: string;
commentAllowed?: number;
}
export interface LNURLStatus {
status: "SUCCESS" | "ERROR";
reason?: string;
}
export interface LNURLInvoice extends LNURLStatus {
pr?: string;
successAction?: LNURLSuccessAction;
}
export interface LNURLSuccessAction {
description?: string;
url?: string;
}

View File

@ -78,9 +78,9 @@ export async function generateNewLogin() {
}
const publicKey = utils.bytesToHex(secp.schnorr.getPublicKey(privateKey));
const publisher = new EventPublisher(System, publicKey, privateKey);
const publisher = new EventPublisher(publicKey, privateKey);
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
LoginStore.loginWithPrivateKey(privateKey, entropy, newRelays);
}

View File

@ -2,11 +2,10 @@ import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { HexKey, RelaySettings } from "@snort/system";
import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
import { DefaultRelays } from "Const";
import ExternalStore from "ExternalStore";
import { LoginSession } from "Login";
import { deepClone, sanitizeRelayUrl, unwrap } from "SnortUtils";
import { DefaultPreferences, UserPreferences } from "./Preferences";
const AccountStoreKey = "sessions";

View File

@ -4,7 +4,7 @@ import { TaggedRawEvent, EventKind, MetadataCache } from "@snort/system";
import { getDisplayName } from "Element/ProfileImage";
import { MentionRegex } from "Const";
import { tagFilterOfTextRepost, unwrap } from "SnortUtils";
import { UserCache } from "Cache/UserCache";
import { UserCache } from "Cache";
import { LoginSession } from "Login";
export interface NotificationRequest {

View File

@ -6,6 +6,7 @@ import Timeline from "Element/Timeline";
import useEventPublisher from "Feed/EventPublisher";
import useLogin from "Hooks/useLogin";
import { setTags } from "Login";
import { System } from "index";
const HashTagsPage = () => {
const params = useParams();
@ -19,7 +20,7 @@ const HashTagsPage = () => {
async function followTags(ts: string[]) {
if (publisher) {
const ev = await publisher.tags(ts);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
setTags(login, ts, ev.created_at * 1000);
}
}

View File

@ -14,7 +14,6 @@ import useLoginFeed from "Feed/LoginFeed";
import { totalUnread } from "Pages/MessagesPage";
import useModeration from "Hooks/useModeration";
import { NoteCreator } from "Element/NoteCreator";
import useEventPublisher from "Feed/EventPublisher";
import { useDmCache } from "Hooks/useDmsCache";
import { mapPlanName } from "./subscribe";
import useLogin from "Hooks/useLogin";
@ -34,7 +33,6 @@ export default function Layout() {
const { publicKey, relays, preferences, subscriptions } = useLogin();
const currentSubscription = getCurrentSubscription(subscriptions);
const [pageClass, setPageClass] = useState("page");
const pub = useEventPublisher();
useLoginFeed();
const handleNoteCreatorButtonClick = () => {
@ -63,12 +61,6 @@ export default function Layout() {
}
}, [location]);
useEffect(() => {
if (pub) {
System.HandleAuth = pub.nip42Auth.bind(pub);
}
}, [pub]);
useEffect(() => {
if (relays) {
(async () => {

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import { encodeTLV, EventKind, HexKey, NostrPrefix, tryParseNostrLink } from "@snort/system";
import { LNURL } from "@snort/shared";
import { getReactions, unwrap } from "SnortUtils";
import { formatShort } from "Number";
@ -43,7 +44,6 @@ import { ProxyImg } from "Element/ProxyImg";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
import { EmailRegex } from "Const";
import { getNip05PubKey } from "Pages/LoginPage";
import { LNURL } from "LNURL";
import useLogin from "Hooks/useLogin";
import messages from "./messages";

View File

@ -9,9 +9,10 @@ import useLogin from "Hooks/useLogin";
import { useUserProfile } from "Hooks/useUserProfile";
import { UserCache } from "Cache";
import AvatarEditor from "Element/AvatarEditor";
import { DISCOVER } from ".";
import { System } from "index";
import messages from "./messages";
import { DISCOVER } from ".";
export default function ProfileSetup() {
const login = useLogin();
@ -36,7 +37,7 @@ export default function ProfileSetup() {
name: username,
picture,
});
publisher.broadcast(ev);
System.BroadcastEvent(ev);
const profile = mapEventToProfile(ev);
if (profile) {
UserCache.set(profile);

View File

@ -5,6 +5,7 @@ import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { mapEventToProfile } from "@snort/system";
import { System } from "index";
import useEventPublisher from "Feed/EventPublisher";
import { useUserProfile } from "Hooks/useUserProfile";
import { openFile } from "SnortUtils";
@ -77,7 +78,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
if (publisher) {
const ev = await publisher.metadata(userCopy);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
const newProfile = mapEventToProfile(ev);
if (newProfile) {

View File

@ -22,12 +22,14 @@ const RelaySettingsPage = () => {
async function saveRelays() {
if (publisher) {
const ev = await publisher.contactList(login.follows.item, login.relays.item);
publisher.broadcast(ev);
System.BroadcastEvent(ev);
try {
const onlineRelays = await fetch("https://api.nostr.watch/v1/online").then(r => r.json());
const relayList = await publisher.relayList(login.relays.item);
const rs = Object.keys(relays.item).concat(randomSample(onlineRelays, 20));
publisher.broadcastAll(relayList, rs);
rs.forEach(r => {
System.WriteOnceToRelay(r, relayList);
});
} catch (error) {
console.error(error);
}

View File

@ -1,10 +1,10 @@
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { LNURL } from "@snort/shared";
import { ApiHost } from "Const";
import AsyncButton from "Element/AsyncButton";
import useEventPublisher from "Feed/EventPublisher";
import { LNURL } from "LNURL";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
export default function LNForwardAddress({ handle }: { handle: ManageHandle }) {

View File

@ -4,8 +4,7 @@ import { sha256 as hash } from "@noble/hashes/sha256";
import { hmac } from "@noble/hashes/hmac";
import { bytesToHex } from "@noble/hashes/utils";
import { decode as invoiceDecode } from "light-bolt11-decoder";
import { bech32 } from "bech32";
import base32Decode from "base32-decode";
import { bech32, base32hex } from "@scure/base";
import {
HexKey,
TaggedRawEvent,
@ -433,8 +432,8 @@ export function magnetURIDecode(uri: string): Magnet | undefined {
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))];
const decodedStr = base32hex.decode(m[1]);
result.infoHash = [bytesToHex(decodedStr)];
} else if ((m = xt.match(/^urn:btmh:1220(.{64})/))) {
result.infoHashV2 = [m[1].toLowerCase()];
}

View File

@ -1,9 +1,8 @@
import { ReactNode, useSyncExternalStore } from "react";
import { v4 as uuid } from "uuid";
import ExternalStore from "ExternalStore";
import Icon from "Icons/Icon";
import { unixNow } from "SnortUtils";
import { ExternalStore, unixNow } from "@snort/shared";
import Icon from "Icons/Icon";
import "./Toaster.css";
interface ToastNotification {

View File

@ -12,7 +12,7 @@ import {
WalletKind,
WalletStore,
} from "Wallet";
import { barrierQueue, processWorkQueue, WorkQueueItem } from "WorkQueue";
import { barrierQueue, processWorkQueue, WorkQueueItem } from "@snort/shared";
const WebLNQueue: Array<WorkQueueItem> = [];
processWorkQueue(WebLNQueue);

View File

@ -1,6 +1,6 @@
import { useEffect, useSyncExternalStore } from "react";
import ExternalStore from "ExternalStore";
import { ExternalStore } from "@snort/shared";
import { decodeInvoice, unwrap } from "SnortUtils";
import LNDHubWallet from "./LNDHub";
import { NostrConnectWallet } from "./NostrWalletConnect";

View File

@ -1,30 +0,0 @@
export interface WorkQueueItem {
next: () => Promise<unknown>;
resolve(v: unknown): void;
reject(e: unknown): void;
}
export async function processWorkQueue(queue?: Array<WorkQueueItem>, queueDelay = 200) {
while (queue && queue.length > 0) {
const v = queue.shift();
if (v) {
try {
const ret = await v.next();
v.resolve(ret);
} catch (e) {
v.reject(e);
}
}
}
setTimeout(() => processWorkQueue(queue, queueDelay), queueDelay);
}
export const barrierQueue = async <T>(queue: Array<WorkQueueItem>, then: () => Promise<T>): Promise<T> => {
return new Promise<T>((resolve, reject) => {
queue.push({
next: then,
resolve,
reject,
});
});
};

View File

@ -1,9 +1,7 @@
import { UserCache } from "Cache";
import { getDisplayName } from "Element/ProfileImage";
import ExternalStore from "ExternalStore";
import { LNURL } from "LNURL";
import { LNURL, ExternalStore, unixNow } from "@snort/shared";
import { Toastore } from "Toaster";
import { unixNow } from "SnortUtils";
import { LNWallet, WalletInvoiceState, Wallets } from "Wallet";
export enum ZapPoolRecipientType {

View File

@ -35,14 +35,21 @@ import DebugPage from "Pages/Debug";
import { db } from "Db";
import { preload, UserCache } from "Cache";
import { LoginStore } from "Login";
import { NostrSystem, ProfileLoaderService } from "@snort/system";
import { UserRelays } from "Cache/UserRelayCache";
import { EventPublisher, NostrSystem, ProfileLoaderService } from "@snort/system";
import { UserRelays } from "Cache";
/**
* Singleton nostr system
*/
export const System = new NostrSystem({
get: pk => UserRelays.getFromCache(pk)?.relays,
relayCache: UserRelays,
authHandler: async (c, r) => {
const { publicKey, privateKey } = LoginStore.snapshot();
if (publicKey) {
const pub = new EventPublisher(publicKey, privateKey);
return await pub.nip42Auth(c, r);
}
},
});
/**

View File

@ -3,18 +3,16 @@ import {} from ".";
declare const self: ServiceWorkerGlobalScope;
import { clientsClaim } from "workbox-core";
import { ExpirationPlugin } from "workbox-expiration";
import { registerRoute } from "workbox-routing";
import { StaleWhileRevalidate, CacheFirst } from "workbox-strategies";
import { CacheFirst } from "workbox-strategies";
clientsClaim();
const staticTypes = ["image", "video", "audio"];
const staticTypes = ["image", "video", "audio", "script", "style", "font"];
registerRoute(
({ request, url }) => url.origin === self.location.origin && staticTypes.includes(request.destination),
new StaleWhileRevalidate({
new CacheFirst({
cacheName: "static-content",
plugins: [new ExpirationPlugin({ maxEntries: 50 })],
})
);