mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 15:14:20 +01:00
- Add project structure with TypeScript, React, and Vite - Implement nostr authentication using browser extension (NIP-07) - Add NIP-51 compliant bookmark fetching and display - Create minimal UI with login and bookmark components - Integrate applesauce-core and applesauce-react libraries - Add responsive styling with dark/light mode support - Include comprehensive README with setup instructions This is a minimal MVP for a nostr bookmark client that allows users to view their bookmarks according to NIP-51 specification.
160 lines
5.6 KiB
JavaScript
160 lines
5.6 KiB
JavaScript
import { nanoid } from "nanoid";
|
|
import { BehaviorSubject } from "rxjs";
|
|
import { getEventHash } from "nostr-tools";
|
|
/** Wraps a promise in an abort signal */
|
|
function wrapInSignal(promise, signal) {
|
|
return new Promise((res, rej) => {
|
|
signal.throwIfAborted();
|
|
let done = false;
|
|
// reject promise if abort signal is triggered
|
|
signal.addEventListener("abort", () => {
|
|
if (!done)
|
|
rej(signal.reason || undefined);
|
|
done = true;
|
|
});
|
|
return promise.then((v) => {
|
|
if (!done)
|
|
res(v);
|
|
done = true;
|
|
}, (err) => {
|
|
if (!done)
|
|
rej(err);
|
|
done = true;
|
|
});
|
|
});
|
|
}
|
|
/** An error thrown when a signer is used with the wrong pubkey */
|
|
export class SignerMismatchError extends Error {
|
|
}
|
|
/** A base class for all accounts */
|
|
export class BaseAccount {
|
|
pubkey;
|
|
signer;
|
|
id = nanoid(8);
|
|
get type() {
|
|
const cls = Reflect.getPrototypeOf(this).constructor;
|
|
return cls.type;
|
|
}
|
|
/** Disable request queueing */
|
|
disableQueue;
|
|
metadata$ = new BehaviorSubject(undefined);
|
|
get metadata() {
|
|
return this.metadata$.value;
|
|
}
|
|
set metadata(metadata) {
|
|
this.metadata$.next(metadata);
|
|
}
|
|
get nip04() {
|
|
if (!this.signer.nip04)
|
|
return undefined;
|
|
return {
|
|
encrypt: async (pubkey, plaintext) => this.operation(() => this.signer.nip04.encrypt(pubkey, plaintext)),
|
|
decrypt: async (pubkey, plaintext) => this.operation(() => this.signer.nip04.decrypt(pubkey, plaintext)),
|
|
};
|
|
}
|
|
get nip44() {
|
|
if (!this.signer.nip44)
|
|
return undefined;
|
|
return {
|
|
encrypt: async (pubkey, plaintext) => this.operation(() => this.signer.nip44.encrypt(pubkey, plaintext)),
|
|
decrypt: async (pubkey, plaintext) => this.operation(() => this.signer.nip44.decrypt(pubkey, plaintext)),
|
|
};
|
|
}
|
|
constructor(pubkey, signer) {
|
|
this.pubkey = pubkey;
|
|
this.signer = signer;
|
|
}
|
|
// This should be overwritten by a sub class
|
|
toJSON() {
|
|
throw new Error("Not implemented");
|
|
}
|
|
/** A method that wraps any signer interaction, this allows the account to wait for unlock or queue requests */
|
|
operation(operation) {
|
|
// If the queue is enabled, wait our turn in the queue
|
|
return this.waitForQueue(operation);
|
|
}
|
|
/** Adds the common fields to the serialized output of a toJSON method */
|
|
saveCommonFields(json) {
|
|
return { ...json, id: this.id, pubkey: this.pubkey, metadata: this.metadata, type: this.type };
|
|
}
|
|
/** Sets an accounts id and metadata. NOTE: This should only be used in fromJSON methods */
|
|
static loadCommonFields(account, json) {
|
|
if (json.id)
|
|
account.id = json.id;
|
|
if (json.metadata)
|
|
account.metadata = json.metadata;
|
|
return account;
|
|
}
|
|
/** Gets the pubkey from the signer */
|
|
getPublicKey() {
|
|
return this.operation(async () => {
|
|
const result = await this.signer.getPublicKey();
|
|
if (this.pubkey !== result)
|
|
throw new SignerMismatchError("Account signer mismatch");
|
|
return result;
|
|
});
|
|
}
|
|
/** sign the event and make sure its signed with the correct pubkey */
|
|
signEvent(template) {
|
|
// If the template does not have a pubkey, set it to the accounts pubkey
|
|
if (!Reflect.has(template, "pubkey"))
|
|
Reflect.set(template, "pubkey", this.pubkey);
|
|
return this.operation(async () => {
|
|
const id = getEventHash(template);
|
|
const result = await this.signer.signEvent(template);
|
|
if (result.pubkey !== this.pubkey)
|
|
throw new SignerMismatchError("Signer signed with wrong pubkey");
|
|
if (result.id !== id)
|
|
throw new SignerMismatchError("Signer modified event");
|
|
return result;
|
|
});
|
|
}
|
|
/** Aborts all pending requests in the queue */
|
|
abortQueue(reason) {
|
|
if (this.abort)
|
|
this.abort.abort(reason);
|
|
}
|
|
/** internal queue */
|
|
queueLength = 0;
|
|
lock = null;
|
|
abort = null;
|
|
reduceQueue() {
|
|
// shorten the queue
|
|
this.queueLength--;
|
|
// if this was the last request, remove the lock
|
|
if (this.queueLength === 0) {
|
|
this.lock = null;
|
|
this.abort = null;
|
|
}
|
|
}
|
|
waitForQueue(operation) {
|
|
if (this.disableQueue)
|
|
return operation();
|
|
// if there is already a pending request, wait for it
|
|
if (this.lock && this.abort) {
|
|
// create a new promise that runs after the lock
|
|
const p = wrapInSignal(this.lock.then(() => {
|
|
// if the abort signal is triggered, don't call the signer
|
|
this.abort?.signal.throwIfAborted();
|
|
return operation();
|
|
}), this.abort.signal);
|
|
// set the lock the new promise that ignores errors
|
|
this.lock = p.catch(() => { }).finally(this.reduceQueue.bind(this));
|
|
this.queueLength++;
|
|
return p;
|
|
}
|
|
else {
|
|
const result = operation();
|
|
// if the result is async, set the new lock
|
|
if (result instanceof Promise) {
|
|
this.abort = new AbortController();
|
|
const p = wrapInSignal(result, this.abort.signal);
|
|
// set the lock the new promise that ignores errors
|
|
this.lock = p.catch(() => { }).finally(this.reduceQueue.bind(this));
|
|
this.queueLength = 1;
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
}
|