feat: improve profile loading
This commit is contained in:
@ -8,10 +8,10 @@ import { ConnectionStats } from "./ConnectionStats";
|
||||
import { RawEvent, RawReqFilter, TaggedRawEvent, u256 } from "./index";
|
||||
import { RelayInfo } from "./RelayInfo";
|
||||
import Nips from "./Nips";
|
||||
import { System } from "./System";
|
||||
import { unwrap } from "./Util";
|
||||
|
||||
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
|
||||
export type AuthHandler = (challenge: string, relay: string) => Promise<NEvent | undefined>;
|
||||
|
||||
/**
|
||||
* Relay settings
|
||||
@ -36,7 +36,7 @@ export type StateSnapshot = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export default class Connection {
|
||||
export class Connection {
|
||||
Id: string;
|
||||
Address: string;
|
||||
Socket: WebSocket | null;
|
||||
@ -53,10 +53,11 @@ export default class Connection {
|
||||
IsClosed: boolean;
|
||||
ReconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
EventsCallback: Map<u256, (msg: boolean[]) => void>;
|
||||
Auth?: AuthHandler;
|
||||
AwaitingAuth: Map<string, boolean>;
|
||||
Authed: boolean;
|
||||
|
||||
constructor(addr: string, options: RelaySettings) {
|
||||
constructor(addr: string, options: RelaySettings, auth: AuthHandler = undefined) {
|
||||
this.Id = uuid();
|
||||
this.Address = addr;
|
||||
this.Socket = null;
|
||||
@ -82,6 +83,7 @@ export default class Connection {
|
||||
this.EventsCallback = new Map();
|
||||
this.AwaitingAuth = new Map();
|
||||
this.Authed = false;
|
||||
this.Auth = auth;
|
||||
}
|
||||
|
||||
async Connect() {
|
||||
@ -384,8 +386,11 @@ export default class Connection {
|
||||
const authCleanup = () => {
|
||||
this.AwaitingAuth.delete(challenge);
|
||||
};
|
||||
if(!this.Auth) {
|
||||
throw new Error("Auth hook not registered");
|
||||
}
|
||||
this.AwaitingAuth.set(challenge, true);
|
||||
const authEvent = await System.nip42Auth(challenge, this.Address);
|
||||
const authEvent = await this.Auth(challenge, this.Address);
|
||||
return new Promise((resolve) => {
|
||||
if (!authEvent) {
|
||||
authCleanup();
|
||||
|
@ -1,149 +1,4 @@
|
||||
import { RelaySettings } from "./Connection";
|
||||
|
||||
/**
|
||||
* Add-on api for snort features
|
||||
*/
|
||||
export const ApiHost = "https://api.snort.social";
|
||||
|
||||
/**
|
||||
* LibreTranslate endpoint
|
||||
*/
|
||||
export const TranslateHost = "https://translate.snort.social";
|
||||
|
||||
/**
|
||||
* Void.cat file upload service url
|
||||
*/
|
||||
export const VoidCatHost = "https://void.cat";
|
||||
|
||||
/**
|
||||
* Kierans pubkey
|
||||
*/
|
||||
export const KieranPubKey =
|
||||
"npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
|
||||
|
||||
/**
|
||||
* Official snort account
|
||||
*/
|
||||
export const SnortPubKey =
|
||||
"npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
|
||||
|
||||
/**
|
||||
* Websocket re-connect timeout
|
||||
*/
|
||||
export const DefaultConnectTimeout = 2000;
|
||||
|
||||
/**
|
||||
* How long profile cache should be considered valid for
|
||||
*/
|
||||
export const ProfileCacheExpire = 1_000 * 60 * 5;
|
||||
|
||||
/**
|
||||
* Default bootstrap relays
|
||||
*/
|
||||
export const DefaultRelays = new Map<string, RelaySettings>([
|
||||
["wss://relay.snort.social", { read: true, write: true }],
|
||||
["wss://eden.nostr.land", { read: true, write: true }],
|
||||
["wss://atlas.nostr.land", { read: true, write: true }],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Default search relays
|
||||
*/
|
||||
export const SearchRelays = new Map<string, RelaySettings>([
|
||||
["wss://relay.nostr.band", { read: true, write: false }],
|
||||
]);
|
||||
|
||||
/**
|
||||
* List of recommended follows for new users
|
||||
*/
|
||||
export const RecommendedFollows = [
|
||||
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
|
||||
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us
|
||||
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55
|
||||
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz
|
||||
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri
|
||||
"A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor
|
||||
"E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK
|
||||
"C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers
|
||||
"85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston
|
||||
"C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut
|
||||
"83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth
|
||||
"3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss
|
||||
"472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent
|
||||
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov
|
||||
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL
|
||||
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
|
||||
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol
|
||||
];
|
||||
|
||||
/**
|
||||
* Regex to match email address
|
||||
*/
|
||||
export const EmailRegex =
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
/**
|
||||
* Generic URL regex
|
||||
*/
|
||||
export const UrlRegex =
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/((?:http|ftp|https):\/\/(?:[\w+?\.\w+])+(?:[a-zA-Z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*)?)/i;
|
||||
|
||||
/**
|
||||
* Extract file extensions regex
|
||||
*/
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
export const FileExtensionRegex = /\.([\w]+)$/i;
|
||||
|
||||
/**
|
||||
* Extract note reactions regex
|
||||
*/
|
||||
export const MentionRegex = /(#\[\d+\])/gi;
|
||||
|
||||
/**
|
||||
* Simple lightning invoice regex
|
||||
*/
|
||||
export const InvoiceRegex = /(lnbc\w+)/i;
|
||||
|
||||
/**
|
||||
* YouTube URL regex
|
||||
*/
|
||||
export const YoutubeUrlRegex =
|
||||
/(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
|
||||
|
||||
/**
|
||||
* Tweet Regex
|
||||
*/
|
||||
export const TweetUrlRegex =
|
||||
/https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
|
||||
|
||||
/**
|
||||
* Hashtag regex
|
||||
*/
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/;
|
||||
|
||||
/**
|
||||
* Tidal share link regex
|
||||
*/
|
||||
export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
|
||||
|
||||
/**
|
||||
* SoundCloud regex
|
||||
*/
|
||||
export const SoundCloudRegex =
|
||||
/soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
|
||||
|
||||
/**
|
||||
* Mixcloud regex
|
||||
*/
|
||||
|
||||
export const MixCloudRegex =
|
||||
/mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
|
||||
|
||||
export const SpotifyRegex =
|
||||
/open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/;
|
||||
export const DefaultConnectTimeout = 2000;
|
@ -1,6 +1,6 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { TaggedRawEvent, RawReqFilter, u256 } from "./index";
|
||||
import Connection from "./Connection";
|
||||
import { Connection } from "./Connection";
|
||||
import EventKind from "./EventKind";
|
||||
|
||||
export type NEventHandler = (e: TaggedRawEvent) => void;
|
||||
|
@ -1,290 +0,0 @@
|
||||
import { HexKey, TaggedRawEvent, UserMetadata } from "./index";
|
||||
import { ProfileCacheExpire } from "./Const";
|
||||
import Connection, { RelaySettings } from "./Connection";
|
||||
import Event from "./Event";
|
||||
import EventKind from "./EventKind";
|
||||
import { Subscriptions } from "./Subscriptions";
|
||||
import { hexToBech32, unwrap } from "./Util";
|
||||
|
||||
// TODO This interface is repeated in State/Users, revisit this.
|
||||
export interface MetadataCache extends UserMetadata {
|
||||
/**
|
||||
* When the object was saved in cache
|
||||
*/
|
||||
loaded: number;
|
||||
|
||||
/**
|
||||
* When the source metadata event was created
|
||||
*/
|
||||
created: number;
|
||||
|
||||
/**
|
||||
* The pubkey of the owner of this metadata
|
||||
*/
|
||||
pubkey: HexKey;
|
||||
|
||||
/**
|
||||
* The bech32 encoded pubkey
|
||||
*/
|
||||
npub: string;
|
||||
}
|
||||
|
||||
// TODO This interface is repeated in State/Users, revisit this.
|
||||
export interface UsersDb {
|
||||
isAvailable(): Promise<boolean>;
|
||||
query(str: string): Promise<MetadataCache[]>;
|
||||
find(key: HexKey): Promise<MetadataCache | undefined>;
|
||||
add(user: MetadataCache): Promise<void>;
|
||||
put(user: MetadataCache): Promise<void>;
|
||||
bulkAdd(users: MetadataCache[]): Promise<void>;
|
||||
bulkGet(keys: HexKey[]): Promise<MetadataCache[]>;
|
||||
bulkPut(users: MetadataCache[]): Promise<void>;
|
||||
update(key: HexKey, fields: Record<string, string | number>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages nostr content retrieval system
|
||||
*/
|
||||
export class NostrSystem {
|
||||
/**
|
||||
* All currently connected websockets
|
||||
*/
|
||||
Sockets: Map<string, Connection>;
|
||||
|
||||
/**
|
||||
* All active subscriptions
|
||||
*/
|
||||
Subscriptions: Map<string, Subscriptions>;
|
||||
|
||||
/**
|
||||
* Pending subscriptions to send when sockets become open
|
||||
*/
|
||||
PendingSubscriptions: Subscriptions[];
|
||||
|
||||
/**
|
||||
* List of pubkeys to fetch metadata for
|
||||
*/
|
||||
WantsMetadata: Set<HexKey>;
|
||||
|
||||
/**
|
||||
* User db store
|
||||
*/
|
||||
UserDb?: UsersDb;
|
||||
|
||||
constructor() {
|
||||
this.Sockets = new Map();
|
||||
this.Subscriptions = new Map();
|
||||
this.PendingSubscriptions = [];
|
||||
this.WantsMetadata = new Set();
|
||||
this._FetchMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a NOSTR relay if not already connected
|
||||
*/
|
||||
async ConnectToRelay(address: string, options: RelaySettings) {
|
||||
try {
|
||||
if (!this.Sockets.has(address)) {
|
||||
const c = new Connection(address, options);
|
||||
await c.Connect();
|
||||
this.Sockets.set(address, c);
|
||||
for (const [, s] of this.Subscriptions) {
|
||||
c.AddSubscription(s);
|
||||
}
|
||||
} else {
|
||||
// update settings if already connected
|
||||
unwrap(this.Sockets.get(address)).Settings = options;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from a relay
|
||||
*/
|
||||
DisconnectRelay(address: string) {
|
||||
const c = this.Sockets.get(address);
|
||||
if (c) {
|
||||
this.Sockets.delete(address);
|
||||
c.Close();
|
||||
}
|
||||
}
|
||||
|
||||
AddSubscriptionToRelay(sub: Subscriptions, relay: string) {
|
||||
this.Sockets.get(relay)?.AddSubscription(sub);
|
||||
}
|
||||
|
||||
AddSubscription(sub: Subscriptions) {
|
||||
for (const [, s] of this.Sockets) {
|
||||
s.AddSubscription(sub);
|
||||
}
|
||||
this.Subscriptions.set(sub.Id, sub);
|
||||
}
|
||||
|
||||
RemoveSubscription(subId: string) {
|
||||
for (const [, s] of this.Sockets) {
|
||||
s.RemoveSubscription(subId);
|
||||
}
|
||||
this.Subscriptions.delete(subId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send events to writable relays
|
||||
*/
|
||||
BroadcastEvent(ev: Event) {
|
||||
for (const [, s] of this.Sockets) {
|
||||
s.SendEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an event to a relay then disconnect
|
||||
*/
|
||||
async WriteOnceToRelay(address: string, ev: Event) {
|
||||
const c = new Connection(address, { write: true, read: false });
|
||||
await c.SendAsync(ev);
|
||||
c.Close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request profile metadata for a set of pubkeys
|
||||
*/
|
||||
TrackMetadata(pk: HexKey | Array<HexKey>) {
|
||||
for (const p of Array.isArray(pk) ? pk : [pk]) {
|
||||
if (p.length > 0) {
|
||||
this.WantsMetadata.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking metadata for a set of pubkeys
|
||||
*/
|
||||
UntrackMetadata(pk: HexKey | Array<HexKey>) {
|
||||
for (const p of Array.isArray(pk) ? pk : [pk]) {
|
||||
if (p.length > 0) {
|
||||
this.WantsMetadata.delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request/Response pattern
|
||||
*/
|
||||
RequestSubscription(sub: Subscriptions) {
|
||||
return new Promise<TaggedRawEvent[]>((resolve) => {
|
||||
const events: TaggedRawEvent[] = [];
|
||||
|
||||
// force timeout returning current results
|
||||
const timeout = setTimeout(() => {
|
||||
this.RemoveSubscription(sub.Id);
|
||||
resolve(events);
|
||||
}, 10_000);
|
||||
|
||||
const onEventPassthrough = sub.OnEvent;
|
||||
sub.OnEvent = (ev) => {
|
||||
if (typeof onEventPassthrough === "function") {
|
||||
onEventPassthrough(ev);
|
||||
}
|
||||
if (!events.some((a) => a.id === ev.id)) {
|
||||
events.push(ev);
|
||||
} else {
|
||||
const existing = events.find((a) => a.id === ev.id);
|
||||
if (existing) {
|
||||
for (const v of ev.relays) {
|
||||
existing.relays.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
sub.OnEnd = (c) => {
|
||||
c.RemoveSubscription(sub.Id);
|
||||
if (sub.IsFinished()) {
|
||||
clearInterval(timeout);
|
||||
console.debug(`[${sub.Id}] Finished`);
|
||||
resolve(events);
|
||||
}
|
||||
};
|
||||
this.AddSubscription(sub);
|
||||
});
|
||||
}
|
||||
|
||||
async _FetchMetadata() {
|
||||
if (this.UserDb) {
|
||||
const missing = new Set<HexKey>();
|
||||
const meta = await this.UserDb.bulkGet(Array.from(this.WantsMetadata));
|
||||
const expire = new Date().getTime() - ProfileCacheExpire;
|
||||
for (const pk of this.WantsMetadata) {
|
||||
const m = meta.find((a) => a?.pubkey === pk);
|
||||
if (!m || m.loaded < expire) {
|
||||
missing.add(pk);
|
||||
// cap 100 missing profiles
|
||||
if (missing.size >= 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
console.debug("Wants profiles: ", missing);
|
||||
|
||||
const sub = new Subscriptions();
|
||||
sub.Id = `profiles:${sub.Id.slice(0, 8)}`;
|
||||
sub.Kinds = new Set([EventKind.SetMetadata]);
|
||||
sub.Authors = missing;
|
||||
sub.OnEvent = async (e) => {
|
||||
const profile = mapEventToProfile(e);
|
||||
const userDb = unwrap(this.UserDb);
|
||||
if (profile) {
|
||||
const existing = await userDb.find(profile.pubkey);
|
||||
if ((existing?.created ?? 0) < profile.created) {
|
||||
await userDb.put(profile);
|
||||
} else if (existing) {
|
||||
await userDb.update(profile.pubkey, {
|
||||
loaded: profile.loaded,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const results = await this.RequestSubscription(sub);
|
||||
const couldNotFetch = Array.from(missing).filter(
|
||||
(a) => !results.some((b) => b.pubkey === a)
|
||||
);
|
||||
console.debug("No profiles: ", couldNotFetch);
|
||||
if (couldNotFetch.length > 0) {
|
||||
const updates = couldNotFetch
|
||||
.map((a) => {
|
||||
return {
|
||||
pubkey: a,
|
||||
loaded: new Date().getTime(),
|
||||
};
|
||||
})
|
||||
.map((a) => unwrap(this.UserDb).update(a.pubkey, a));
|
||||
await Promise.all(updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
setTimeout(() => this._FetchMetadata(), 500);
|
||||
}
|
||||
|
||||
nip42Auth: (challenge: string, relay: string) => Promise<Event | undefined> =
|
||||
async () => undefined;
|
||||
}
|
||||
|
||||
function mapEventToProfile(ev: TaggedRawEvent) {
|
||||
try {
|
||||
const data: UserMetadata = JSON.parse(ev.content);
|
||||
return {
|
||||
pubkey: ev.pubkey,
|
||||
npub: hexToBech32("npub", ev.pubkey),
|
||||
created: ev.created_at,
|
||||
loaded: new Date().getTime(),
|
||||
...data,
|
||||
} as MetadataCache;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse JSON", ev, e);
|
||||
}
|
||||
}
|
||||
|
||||
export const System = new NostrSystem();
|
@ -1,4 +1,3 @@
|
||||
export * from "./System";
|
||||
export * from "./Connection";
|
||||
export { default as EventKind } from "./EventKind";
|
||||
export { Subscriptions } from "./Subscriptions";
|
||||
|
Reference in New Issue
Block a user