reorganize code into smaller files & dirs

This commit is contained in:
Martti Malmi
2024-01-04 15:48:19 +02:00
parent 5ea2eb711f
commit afa6d39a56
321 changed files with 671 additions and 671 deletions

View File

@ -0,0 +1,162 @@
/**
* 1 Hour in seconds
*/
export const Hour = 60 * 60;
/**
* 1 Day in seconds
*/
export const Day = Hour * 24;
/**
* Day this project started
*/
export const Birthday = new Date(2022, 11, 17);
/**
* Add-on api for snort features
*/
export const ApiHost = "https://api.snort.social";
/**
* Kierans pubkey
*/
export const KieranPubKey = "npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
/**
* Official snort account
*/
export const SnortPubKey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
/**
* Default search relays
*/
export const SearchRelays = ["wss://relay.nostr.band"];
export const DeveloperAccounts = [
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // kieran
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0", // Martti
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
"1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411", // Karnage
];
/**
* Snort imgproxy details
*/
export const DefaultImgProxy = {
url: "https://imgproxy.snort.social",
key: "a82fcf26aa0ccb55dfc6b4bd6a1c90744d3be0f38429f21a8828b43449ce7cebe6bdc2b09a827311bef37b18ce35cb1e6b1c60387a254541afa9e5b4264ae942",
salt: "a897770d9abf163de055e9617891214e75a9016d748f8ef865e6ffbcb9ed932295659549773a22a019a5f06d0b440c320be411e3fddfe784e199e4f03d74bd9b",
};
/**
* NIP06-defined derivation path for private keys
*/
export const DerivationPath = "m/44'/1237'/0'/0/0";
/**
* Blaster relays
*/
export const Blasters = ["wss://nostr.mutinywallet.com"];
/**
* 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,}))$/;
/**
* Regex to match a mnemonic seed
*/
export const MnemonicRegex = /(\w+)/g;
/**
* Extract file extensions regex
*/
// eslint-disable-next-line no-useless-escape
export const FileExtensionRegex = /\.([\w]{1,7})$/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\/(?:live\/|shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
/**
* Hashtag regex
*/
// eslint-disable-next-line no-useless-escape
export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/g;
/**
* 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-]+)/;
/**
* Spotify embed regex
*/
export const SpotifyRegex = /open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/;
/**
* Twitch embed regex
*/
export const TwitchRegex = /twitch.tv\/([a-z0-9_]+$)/i;
/**
* Apple Music embed regex
*/
export const AppleMusicRegex =
/music\.apple\.com\/([a-z]{2}\/)?(?:album|playlist)\/[\w\d-]+\/([.a-zA-Z0-9-]+)(?:\?i=\d+)?/i;
/**
* Nostr Nests embed regex
*/
export const NostrNestsRegex = /nostrnests\.com\/[a-zA-Z0-9]+/i;
/*
* Magnet link parser
*/
export const MagnetRegex = /(magnet:[\S]+)/i;
/**
* Wavlake embed regex
*/
export const WavlakeRegex =
/https?:\/\/(?:player\.|www\.)?wavlake\.com\/(?!top|new|artists|account|activity|login|preferences|feed|profile)(?:(?:track|album)\/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}|[a-z-]+)/i;
/*
* Regex to match any base64 string
*/
export const CashuRegex = /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/i;
/*
* Max username length - profile/settings
*/
export const MaxUsernameLength = 100;
/*
* Max about length - profile/settings
*/
export const MaxAboutLength = 1000;

View File

@ -0,0 +1,259 @@
import {
RelaySettings,
EventPublisher,
Nip46Signer,
Nip7Signer,
PrivateKeySigner,
KeyStorage,
SystemInterface,
UserMetadata,
} from "@snort/system";
import { unixNowMs } from "@snort/shared";
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { Blasters } from "@/Utils/Const";
import { LoginStore, UserPreferences, LoginSession, LoginSessionType, SnortAppData, Newest } from "@/Utils/Login/index";
import { generateBip39Entropy, entropyToPrivateKey } from "@/Utils/nip6";
import { bech32ToHex, dedupeById, deleteRefCode, getCountry, sanitizeRelayUrl, unwrap } from "@/Utils";
import { SubscriptionEvent } from "@/Utils/Subscription";
import { Chats, FollowsFeed, GiftsCache, Notifications } from "@/Cache";
import { Nip7OsSigner } from "./Nip7OsSigner";
import SnortApi from "@/External/SnortApi";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (SINGLE_RELAY) {
state.relays.item = {
[SINGLE_RELAY]: { read: true, write: true },
};
state.relays.timestamp = 100;
LoginStore.updateSession(state);
return;
}
if (state.relays.timestamp >= createdAt) {
return;
}
// filter out non-websocket urls
const filtered = new Map<string, RelaySettings>();
for (const [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) {
const url = sanitizeRelayUrl(k);
if (url) {
filtered.set(url, v as RelaySettings);
}
}
}
state.relays.item = Object.fromEntries(filtered.entries());
state.relays.timestamp = createdAt;
LoginStore.updateSession(state);
}
export function removeRelay(state: LoginSession, addr: string) {
delete state.relays.item[addr];
LoginStore.updateSession(state);
}
export function updatePreferences(id: string, p: UserPreferences) {
updateAppData(id, d => {
return {
item: { ...d, preferences: p },
timestamp: unixNowMs(),
};
});
}
export function logout(id: string) {
LoginStore.removeSession(id);
GiftsCache.clear();
Notifications.clear();
FollowsFeed.clear();
Chats.clear();
deleteRefCode();
localStorage.clear();
}
export function markNotificationsRead(state: LoginSession) {
state.readNotifications = unixNowMs();
LoginStore.updateSession(state);
}
export function clearEntropy(state: LoginSession) {
state.generatedEntropy = undefined;
LoginStore.updateSession(state);
}
/**
* Generate a new key and login with this generated key
*/
export async function generateNewLogin(
system: SystemInterface,
pin: (key: string) => Promise<KeyStorage>,
profile: UserMetadata,
) {
const ent = generateBip39Entropy();
const entropy = utils.bytesToHex(ent);
const privateKey = entropyToPrivateKey(ent);
const newRelays = {} as Record<string, RelaySettings>;
// Use current timezone info to determine approx location
// use closest 5 relays
const country = getCountry();
const api = new SnortApi();
const closeRelays = await api.closeRelays(country.lat, country.lon, 20);
for (const cr of closeRelays.sort((a, b) => (a.distance > b.distance ? 1 : -1)).filter(a => !a.is_paid)) {
const rr = sanitizeRelayUrl(cr.url);
if (rr) {
newRelays[rr] = { read: true, write: true };
if (Object.keys(newRelays).length >= 5) {
break;
}
}
}
for (const [k, v] of Object.entries(CONFIG.defaultRelays)) {
if (!newRelays[k]) {
newRelays[k] = v;
}
}
// connect to new relays
await Promise.all(Object.entries(newRelays).map(([k, v]) => system.ConnectToRelay(k, v)));
const publicKey = utils.bytesToHex(secp.schnorr.getPublicKey(privateKey));
const publisher = EventPublisher.privateKey(privateKey);
// Create new contact list following self and site account
const contactList = [publicKey, ...CONFIG.signUp.defaultFollows.map(a => bech32ToHex(a))].map(a => ["p", a]);
const ev = await publisher.contactList(contactList);
system.BroadcastEvent(ev);
// Create relay metadata event
const ev2 = await publisher.relayList(newRelays);
system.BroadcastEvent(ev2);
Promise.all(Blasters.map(a => system.WriteOnceToRelay(a, ev2)));
// Publish new profile
const ev3 = await publisher.metadata(profile);
system.BroadcastEvent(ev3);
Promise.all(Blasters.map(a => system.WriteOnceToRelay(a, ev3)));
LoginStore.loginWithPrivateKey(await pin(privateKey), entropy, newRelays);
}
export function generateRandomKey() {
const privateKey = utils.bytesToHex(secp.schnorr.utils.randomPrivateKey());
const publicKey = utils.bytesToHex(secp.schnorr.getPublicKey(privateKey));
return {
privateKey,
publicKey,
};
}
export function setTags(state: LoginSession, tags: Array<string>, ts: number) {
if (state.tags.timestamp >= ts) {
return;
}
state.tags.item = tags;
state.tags.timestamp = ts;
LoginStore.updateSession(state);
}
export function setMuted(state: LoginSession, muted: Array<string>, ts: number) {
if (state.muted.timestamp >= ts) {
return;
}
state.muted.item = muted;
state.muted.timestamp = ts;
LoginStore.updateSession(state);
}
export function setBlocked(state: LoginSession, blocked: Array<string>, ts: number) {
if (state.blocked.timestamp >= ts) {
return;
}
state.blocked.item = blocked;
state.blocked.timestamp = ts;
LoginStore.updateSession(state);
}
export function setFollows(id: string, follows: Array<string>, ts: number) {
const session = LoginStore.get(id);
if (session) {
if (ts > session.follows.timestamp) {
session.follows.item = follows;
session.follows.timestamp = ts;
LoginStore.updateSession(session);
}
}
}
export function setPinned(state: LoginSession, pinned: Array<string>, ts: number) {
if (state.pinned.timestamp >= ts) {
return;
}
state.pinned.item = pinned;
state.pinned.timestamp = ts;
LoginStore.updateSession(state);
}
export function setBookmarked(state: LoginSession, bookmarked: Array<string>, ts: number) {
if (state.bookmarked.timestamp >= ts) {
return;
}
state.bookmarked.item = bookmarked;
state.bookmarked.timestamp = ts;
LoginStore.updateSession(state);
}
export function setAppData(state: LoginSession, data: SnortAppData, ts: number) {
if (state.appData.timestamp >= ts) {
return;
}
state.appData.item = data;
state.appData.timestamp = ts;
LoginStore.updateSession(state);
}
export function updateAppData(id: string, fn: (data: SnortAppData) => Newest<SnortAppData>) {
const session = LoginStore.get(id);
if (session) {
const next = fn(session.appData.item);
if (next.timestamp > session.appData.timestamp) {
session.appData = next;
LoginStore.updateSession(session);
}
}
}
export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[]) {
const newSubs = dedupeById([...(state.subscriptions || []), ...subs]);
if (newSubs.length !== state.subscriptions.length) {
state.subscriptions = newSubs;
LoginStore.updateSession(state);
}
}
export function sessionNeedsPin(l: LoginSession) {
return l.privateKeyData && l.privateKeyData.shouldUnlock();
}
export function createPublisher(l: LoginSession) {
switch (l.type) {
case LoginSessionType.PrivateKey: {
return EventPublisher.privateKey(unwrap(l.privateKeyData as KeyStorage).value);
}
case LoginSessionType.Nip46: {
const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
const inner = new PrivateKeySigner(unwrap(l.privateKeyData as KeyStorage).value);
const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner);
return new EventPublisher(nip46, unwrap(l.publicKey));
}
case LoginSessionType.Nip7os: {
return new EventPublisher(new Nip7OsSigner(), unwrap(l.publicKey));
}
case LoginSessionType.Nip7: {
return new EventPublisher(new Nip7Signer(), unwrap(l.publicKey));
}
}
}

View File

@ -0,0 +1,134 @@
import { HexKey, RelaySettings, u256, KeyStorage } from "@snort/system";
import { UserPreferences } from "@/Utils/Login/index";
import { SubscriptionEvent } from "@/Utils/Subscription";
import { DisplayAs } from "@/Components/Feed/DisplayAsSelector";
/**
* Stores latest copy of an item
*/
export interface Newest<T> {
item: T;
timestamp: number;
}
export const enum LoginSessionType {
PrivateKey = "private_key",
PublicKey = "public_key",
Nip7 = "nip7",
Nip46 = "nip46",
Nip7os = "nip7_os",
}
export interface SnortAppData {
mutedWords: Array<string>;
showContentWarningPosts: boolean;
preferences: UserPreferences;
}
export interface LoginSession {
/**
* Unique ID to identify this session
*/
id: string;
/**
* Type of login session
*/
type: LoginSessionType;
/**
* Current user private key
* @deprecated Moving to pin encrypted storage
*/
privateKey?: HexKey;
/**
* If this session cannot sign events
*/
readonly: boolean;
/**
* Encrypted private key
*/
privateKeyData?: KeyStorage;
/**
* BIP39-generated, hex-encoded entropy
*/
generatedEntropy?: string;
/**
* Current users public key
*/
publicKey?: HexKey;
/**
* All the logged in users relays
*/
relays: Newest<Record<string, RelaySettings>>;
/**
* A list of pubkeys this user follows
*/
follows: Newest<Array<HexKey>>;
/**
* A list of tags this user follows
*/
tags: Newest<Array<string>>;
/**
* A list of event ids this user has pinned
*/
pinned: Newest<Array<u256>>;
/**
* A list of event ids this user has bookmarked
*/
bookmarked: Newest<Array<u256>>;
/**
* A list of pubkeys this user has muted
*/
muted: Newest<Array<HexKey>>;
/**
* A list of pubkeys this user has muted privately
*/
blocked: Newest<Array<HexKey>>;
/**
* Timestamp of last read notification
*/
readNotifications: number;
/**
* Snort subscriptions licences
*/
subscriptions: Array<SubscriptionEvent>;
/**
* Remote signer relays (NIP-46)
*/
remoteSignerRelays?: Array<string>;
/**
* Snort application data
*/
appData: Newest<SnortAppData>;
/**
* A list of chats which we have joined (NIP-28/NIP-29)
*/
extraChats: Array<string>;
/**
* Is login session in stalker mode
*/
stalker: boolean;
/**
* Display feed as list or grid
*/
feedDisplayAs?: DisplayAs;
}

View File

@ -0,0 +1,327 @@
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { v4 as uuid } from "uuid";
import { HexKey, RelaySettings, EventPublisher, KeyStorage, NotEncrypted, socialGraphInstance } from "@snort/system";
import { deepClone, unwrap, ExternalStore } from "@snort/shared";
import { LoginSession, LoginSessionType, createPublisher } from "@/Utils/Login/index";
import { DefaultPreferences, UserPreferences } from "./Preferences";
const AccountStoreKey = "sessions";
const LoggedOut = {
id: "default",
type: "public_key",
readonly: true,
tags: {
item: [],
timestamp: 0,
},
follows: {
item: [],
timestamp: 0,
},
muted: {
item: [],
timestamp: 0,
},
blocked: {
item: [],
timestamp: 0,
},
bookmarked: {
item: [],
timestamp: 0,
},
pinned: {
item: [],
timestamp: 0,
},
relays: {
item: CONFIG.defaultRelays,
timestamp: 0,
},
latestNotification: 0,
readNotifications: 0,
subscriptions: [],
appData: {
item: {
mutedWords: [],
preferences: DefaultPreferences,
showContentWarningPosts: false,
},
timestamp: 0,
},
extraChats: [],
stalker: false,
} as LoginSession;
export class MultiAccountStore extends ExternalStore<LoginSession> {
#activeAccount?: HexKey;
#accounts: Map<string, LoginSession>;
#publishers = new Map<string, EventPublisher>();
constructor() {
super();
const existing = window.localStorage.getItem(AccountStoreKey);
if (existing) {
const logins = JSON.parse(existing);
this.#accounts = new Map((logins as Array<LoginSession>).map(a => [a.id, a]));
} else {
this.#accounts = new Map();
}
this.#migrate();
if (!this.#activeAccount) {
this.#activeAccount = this.#accounts.keys().next().value;
}
if (this.#activeAccount) {
const pubKey = this.#accounts.get(this.#activeAccount)?.publicKey;
socialGraphInstance.setRoot(pubKey || "");
}
for (const [, v] of this.#accounts) {
// reset readonly on load
if (v.type === LoginSessionType.PrivateKey && v.readonly) {
v.readonly = false;
}
// fill possibly undefined (migrate up)
v.appData ??= {
item: {
mutedWords: [],
preferences: DefaultPreferences,
},
timestamp: 0,
};
v.extraChats ??= [];
if (v.privateKeyData) {
v.privateKeyData = KeyStorage.fromPayload(v.privateKeyData as object);
}
}
this.#loadIrisKeyIfExists();
}
getSessions() {
return [...this.#accounts.values()].map(v => ({
pubkey: unwrap(v.publicKey),
id: v.id,
}));
}
get(id: string) {
const s = this.#accounts.get(id);
if (s) {
return { ...s };
}
}
allSubscriptions() {
return [...this.#accounts.values()].map(a => a.subscriptions).flat();
}
switchAccount(id: string) {
if (this.#accounts.has(id)) {
this.#activeAccount = id;
const pubKey = this.#accounts.get(id)?.publicKey || "";
socialGraphInstance.setRoot(pubKey);
this.#save();
}
}
getPublisher(id: string) {
return this.#publishers.get(id);
}
setPublisher(id: string, pub: EventPublisher) {
this.#publishers.set(id, pub);
this.notifyChange();
}
loginWithPubkey(
key: HexKey,
type: LoginSessionType,
relays?: Record<string, RelaySettings>,
remoteSignerRelays?: Array<string>,
privateKey?: KeyStorage,
stalker?: boolean,
) {
if (this.#accounts.has(key)) {
throw new Error("Already logged in with this pubkey");
}
socialGraphInstance.setRoot(key);
const initRelays = this.decideInitRelays(relays);
const newSession = {
...LoggedOut,
id: uuid(),
readonly: type === LoginSessionType.PublicKey,
type,
publicKey: key,
relays: {
item: initRelays,
timestamp: 1,
},
preferences: deepClone(DefaultPreferences),
remoteSignerRelays,
privateKeyData: privateKey,
stalker: stalker ?? false,
} as LoginSession;
const pub = createPublisher(newSession);
if (pub) {
this.setPublisher(newSession.id, pub);
}
this.#accounts.set(newSession.id, newSession);
this.#activeAccount = newSession.id;
this.#save();
return newSession;
}
decideInitRelays(relays: Record<string, RelaySettings> | undefined): Record<string, RelaySettings> {
if (SINGLE_RELAY) return { [SINGLE_RELAY]: { read: true, write: true } };
if (relays && Object.keys(relays).length > 0) {
return relays;
}
return CONFIG.defaultRelays;
}
loginWithPrivateKey(key: KeyStorage, entropy?: string, relays?: Record<string, RelaySettings>) {
const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key.value));
if (this.#accounts.has(pubKey)) {
throw new Error("Already logged in with this pubkey");
}
socialGraphInstance.setRoot(pubKey);
const initRelays = this.decideInitRelays(relays);
const newSession = {
...LoggedOut,
id: uuid(),
type: LoginSessionType.PrivateKey,
readonly: false,
privateKeyData: key,
publicKey: pubKey,
generatedEntropy: entropy,
relays: {
item: initRelays,
timestamp: 1,
},
preferences: deepClone(DefaultPreferences),
} as LoginSession;
if ("nostr_os" in window && window.nostr_os) {
window.nostr_os.saveKey(key.value);
newSession.type = LoginSessionType.Nip7os;
newSession.privateKeyData = undefined;
}
const pub = EventPublisher.privateKey(key.value);
this.setPublisher(newSession.id, pub);
this.#accounts.set(newSession.id, newSession);
this.#activeAccount = newSession.id;
this.#save();
return newSession;
}
updateSession(s: LoginSession) {
if (this.#accounts.has(s.id)) {
this.#accounts.set(s.id, s);
console.debug("SET SESSION", s);
this.#save();
}
}
removeSession(id: string) {
if (this.#accounts.delete(id)) {
if (this.#activeAccount === id) {
this.#activeAccount = undefined;
}
this.#save();
}
}
takeSnapshot(): LoginSession {
const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined;
if (!s) return LoggedOut;
return { ...s };
}
#loadIrisKeyIfExists() {
try {
const irisKeyJSON = window.localStorage.getItem("iris.myKey");
if (irisKeyJSON) {
const irisKeyObj = JSON.parse(irisKeyJSON);
if (irisKeyObj.priv) {
const privateKey = new NotEncrypted(irisKeyObj.priv);
this.loginWithPrivateKey(privateKey);
window.localStorage.removeItem("iris.myKey");
}
}
} catch (e) {
console.error("Failed to load iris key", e);
}
}
#migrate() {
let didMigrate = false;
// update session types
for (const [, v] of this.#accounts) {
if (!v.type) {
v.type = v.privateKey ? LoginSessionType.PrivateKey : LoginSessionType.Nip7;
didMigrate = true;
}
}
// add ids
for (const [, v] of this.#accounts) {
if ((v.id?.length ?? 0) === 0) {
v.id = uuid();
didMigrate = true;
}
}
// mark readonly
for (const [, v] of this.#accounts) {
if (v.type === LoginSessionType.PublicKey && !v.readonly) {
v.readonly = true;
didMigrate = true;
}
// reset readonly on load
if (v.type === LoginSessionType.PrivateKey && v.readonly) {
v.readonly = false;
didMigrate = true;
}
}
// move preferences to appdata
for (const [, v] of this.#accounts) {
if ("preferences" in v) {
v.appData.item.preferences = v.preferences as UserPreferences;
delete v["preferences"];
didMigrate = true;
}
}
if (didMigrate) {
console.debug("Finished migration in MultiAccountStore");
this.#save();
}
}
#save() {
if (!this.#activeAccount && this.#accounts.size > 0) {
this.#activeAccount = this.#accounts.keys().next().value;
}
const toSave = [];
for (const v of this.#accounts.values()) {
if (v.privateKeyData instanceof KeyStorage) {
toSave.push({
...v,
privateKeyData: v.privateKeyData.toPayload(),
});
} else {
toSave.push({ ...v });
}
}
window.localStorage.setItem(AccountStoreKey, JSON.stringify(toSave));
this.notifyChange();
}
}

View File

@ -0,0 +1,48 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { EventSigner, NostrEvent } from "@snort/system";
import { Nip7os } from "@/Utils/Login/index";
export class Nip7OsSigner implements EventSigner {
#interface: Nip7os;
constructor() {
if ("nostr_os" in window && window.nostr_os) {
this.#interface = window.nostr_os;
} else {
throw new Error("Nost OS extension not available");
}
}
get supports(): string[] {
return ["nip04"];
}
init(): Promise<void> {
return Promise.resolve();
}
getPubKey(): string | Promise<string> {
return this.#interface.getPublicKey();
}
nip4Encrypt(content: string, key: string): Promise<string> {
return Promise.resolve(this.#interface.nip04_encrypt(content, key));
}
nip4Decrypt(content: string, otherKey: string): Promise<string> {
return Promise.resolve(this.#interface.nip04_decrypt(content, otherKey));
}
nip44Encrypt(content: string, key: string): Promise<string> {
throw new Error("Method not implemented.");
}
nip44Decrypt(content: string, otherKey: string): Promise<string> {
throw new Error("Method not implemented.");
}
sign(ev: NostrEvent): Promise<NostrEvent> {
const ret = this.#interface.signEvent(JSON.stringify(ev));
return Promise.resolve(JSON.parse(ret) as NostrEvent);
}
}

View File

@ -0,0 +1,119 @@
import { DefaultImgProxy } from "@/Utils/Const";
import { ImgProxySettings } from "@/Hooks/useImgProxy";
export interface UserPreferences {
/**
* User selected language
*/
language?: string;
/**
* Enable reactions / reposts / zaps
*/
enableReactions: boolean;
/**
* Reaction emoji
*/
reactionEmoji: string;
/**
* Automatically load media (show link only) (bandwidth/privacy)
*/
autoLoadMedia: "none" | "follows-only" | "all";
/**
* Select between light/dark theme
*/
theme: "system" | "light" | "dark";
/**
* Ask for confirmation when reposting notes
*/
confirmReposts: boolean;
/**
* Automatically show the latests notes
*/
autoShowLatest: boolean;
/**
* Show debugging menus to help diagnose issues
*/
showDebugMenus: boolean;
/**
* File uploading service to upload attachments to
*/
fileUploader: "void.cat" | "nostr.build" | "nostrimg.com" | "void.cat-NIP96" | "nostrcheck.me";
/**
* Use imgproxy to optimize images
*/
imgProxyConfig?: ImgProxySettings;
/**
* Default page to select on load
*/
defaultRootTab: "notes" | "conversations" | "global";
/**
* Default zap amount
*/
defaultZapAmount: number;
/**
* Auto-zap every post
*/
autoZap: boolean;
/**
* Proof-of-Work to apply to all events
*/
pow?: number;
/**
* Collect usage metrics
*/
telemetry?: boolean;
/**
* Show badges on profiles
*/
showBadges?: boolean;
/**
* Show user status messages on profiles
*/
showStatus?: boolean;
/**
* Check event signatures
*/
checkSigs: boolean;
/**
* Auto-translate when available
*/
autoTranslate?: boolean;
}
export const DefaultPreferences = {
enableReactions: true,
reactionEmoji: "+",
autoLoadMedia: "all",
theme: "system",
confirmReposts: false,
showDebugMenus: true,
autoShowLatest: false,
fileUploader: "void.cat",
imgProxyConfig: DefaultImgProxy,
defaultRootTab: "notes",
defaultZapAmount: 50,
autoZap: false,
telemetry: true,
showBadges: false,
showStatus: true,
checkSigs: true,
autoTranslate: true,
} as UserPreferences;

View File

@ -0,0 +1,21 @@
import { MultiAccountStore } from "./MultiAccountStore";
export const LoginStore = new MultiAccountStore();
export interface Nip7os {
getPublicKey: () => string;
signEvent: (ev: string) => string;
saveKey: (key: string) => void;
nip04_encrypt: (content: string, to: string) => string;
nip04_decrypt: (content: string, from: string) => string;
}
declare global {
interface Window {
nostr_os?: Nip7os;
}
}
export * from "./Preferences";
export * from "./LoginSession";
export * from "./Functions";

View File

@ -0,0 +1,126 @@
import { throwIfOffline } from "@snort/shared";
export type ServiceErrorCode =
| "UNKNOWN_ERROR"
| "INVALID_BODY"
| "NO_SUCH_DOMAIN"
| "TOO_SHORT"
| "TOO_LONG"
| "REGEX"
| "DISALLOWED"
| "REGISTERED"
| "NOT_AVAILABLE"
| "RATE_LIMITED"
| "NO_TOKEN"
| "INVALID_TOKEN"
| "NO_SUCH_PAYMENT"
| "INTERNAL_PAYMENT_CHECK_ERROR";
export interface ServiceError {
error: ServiceErrorCode;
errors: Array<string>;
}
export interface ServiceConfig {
domains: DomainConfig[];
}
export type DomainConfig = {
name: string;
default: boolean;
length: [number, number];
regex: [string, string];
regexChars: [string, string];
};
export type HandleAvailability = {
available: boolean;
why?: ServiceErrorCode;
reasonTag?: string | null;
quote?: HandleQuote;
};
export type HandleQuote = {
price: number;
data: HandleData;
};
export type HandleData = {
type: string | "premium" | "short";
};
export type HandleRegisterResponse = {
quote: HandleQuote;
paymentHash: string;
invoice: string;
token: string;
};
export type CheckRegisterResponse = {
available: boolean;
paid: boolean;
password: string;
};
export class ServiceProvider {
readonly url: URL | string;
constructor(url: URL | string) {
this.url = url;
}
async GetConfig(): Promise<ServiceConfig | ServiceError> {
return await this.getJson("/config.json");
}
async CheckAvailable(handle: string, domain: string): Promise<HandleAvailability | ServiceError> {
return await this.getJson("/registration/availability", "POST", {
name: handle,
domain,
});
}
async RegisterHandle(handle: string, domain: string, pubkey: string): Promise<HandleRegisterResponse | ServiceError> {
return await this.getJson("/registration/register", "PUT", {
name: handle,
domain,
pk: pubkey,
ref: "snort",
});
}
async CheckRegistration(token: string): Promise<CheckRegisterResponse | ServiceError> {
return await this.getJson("/registration/register/check", "POST", undefined, {
authorization: token,
});
}
protected async getJson<T>(
path: string,
method?: "GET" | string,
body?: unknown,
headers?: { [key: string]: string },
): Promise<T | ServiceError> {
throwIfOffline();
try {
const rsp = await fetch(`${this.url}${path}`, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
accept: "application/json",
...(body ? { "content-type": "application/json" } : {}),
...headers,
},
});
const obj = await rsp.json();
if ("error" in obj) {
return obj as ServiceError;
}
return obj as T;
} catch (e) {
console.warn(e);
}
return { error: "UNKNOWN_ERROR", errors: [] };
}
}

View File

@ -0,0 +1,77 @@
import { EventKind, EventPublisher } from "@snort/system";
import { ServiceError, ServiceProvider } from "./ServiceProvider";
export interface ManageHandle {
id: string;
handle: string;
domain: string;
pubkey: string;
created: Date;
lnAddress?: string;
forwardType?: ForwardType;
}
export enum ForwardType {
Redirect = 0,
ProxyDirect = 1,
ProxyTrusted = 2,
}
export interface PatchHandle {
lnAddress?: string;
forwardType?: ForwardType;
}
export default class SnortServiceProvider extends ServiceProvider {
readonly #publisher: EventPublisher;
constructor(publisher: EventPublisher, url: string | URL) {
super(url);
this.#publisher = publisher;
}
async list() {
return this.getJsonAuthd<Array<ManageHandle>>("/list", "GET");
}
async transfer(id: string, to: string) {
return this.getJsonAuthd<object>(`/${id}/transfer?to=${to}`, "PATCH");
}
async patch(id: string, obj: PatchHandle) {
return this.getJsonAuthd<object>(`/${id}`, "PATCH", obj);
}
async registerForSubscription(handle: string, domain: string, id: string) {
return this.getJsonAuthd<object>(`/registration/register/${id}`, "PUT", {
name: handle,
domain,
pk: "",
ref: "snort",
});
}
async getJsonAuthd<T>(
path: string,
method?: "GET" | string,
body?: unknown,
headers?: { [key: string]: string },
): Promise<T | ServiceError> {
const auth = await this.#publisher.generic(eb => {
eb.kind(EventKind.HttpAuthentication);
eb.tag(["url", `${this.url}${path}`]);
eb.tag(["method", method ?? "GET"]);
return eb;
});
if (!auth) {
return {
error: "INVALID_TOKEN",
} as ServiceError;
}
return this.getJson<T>(path, method, body, {
...headers,
authorization: `Nostr ${window.btoa(JSON.stringify(auth))}`,
});
}
}

View File

@ -0,0 +1,26 @@
import { throwIfOffline } from "@snort/shared";
interface NostrJson {
names: Record<string, string>;
}
export async function fetchNip05Pubkey(name: string, domain: string, timeout = 2_000) {
if (!name || !domain) {
return undefined;
}
try {
throwIfOffline();
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`, {
signal: AbortSignal.timeout(timeout),
});
const data: NostrJson = await res.json();
const match = Object.keys(data.names).find(n => {
return n.toLowerCase() === name.toLowerCase();
});
return match ? data.names[match] : undefined;
} catch {
// ignored
}
return undefined;
}

View File

@ -0,0 +1,107 @@
import { TaggedNostrEvent, EventKind, MetadataCache, EventPublisher } from "@snort/system";
import { MentionRegex } from "@/Utils/Const";
import { defaultAvatar, tagFilterOfTextRepost, getDisplayName } from "@/Utils/index";
import { UserCache } from "@/Cache";
import { LoginSession } from "@/Utils/Login";
import { removeUndefined, unwrap } from "@snort/shared";
import SnortApi from "@/External/SnortApi";
import { base64 } from "@scure/base";
export interface NotificationRequest {
title: string;
body: string;
icon: string;
timestamp: number;
}
export async function makeNotification(ev: TaggedNostrEvent): Promise<NotificationRequest | null> {
switch (ev.kind) {
case EventKind.TextNote: {
if (ev.tags.some(tagFilterOfTextRepost(ev))) {
return null;
}
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1])]);
await UserCache.buffer([...pubkeys]);
const allUsers = removeUndefined([...pubkeys].map(a => UserCache.getFromCache(a)));
const fromUser = UserCache.getFromCache(ev.pubkey);
const name = getDisplayName(fromUser, ev.pubkey);
const avatarUrl = fromUser?.picture || defaultAvatar(ev.pubkey);
return {
title: `Reply from ${name}`,
body: replaceTagsWithUser(ev, allUsers).substring(0, 50),
icon: avatarUrl,
timestamp: ev.created_at * 1000,
};
}
}
return null;
}
function replaceTagsWithUser(ev: TaggedNostrEvent, users: MetadataCache[]) {
return ev.content
.split(MentionRegex)
.map(match => {
const matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
const idx = parseInt(matchTag[1]);
const ref = ev.tags[idx];
if (ref && ref[0] === "p" && ref.length > 1) {
const u = users.find(a => a.pubkey === ref[1]);
return `@${getDisplayName(u, ref[1])}`;
}
}
return match;
})
.join();
}
export async function sendNotification(state: LoginSession, req: NotificationRequest) {
const hasPermission = "Notification" in window && Notification.permission === "granted";
const shouldShowNotification = hasPermission && req.timestamp > state.readNotifications;
if (shouldShowNotification) {
try {
const worker = await navigator.serviceWorker.ready;
worker.showNotification(req.title, {
tag: "notification",
vibrate: [500],
...req,
});
} catch (error) {
console.warn(error);
}
}
}
export async function subscribeToNotifications(publisher: EventPublisher) {
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
const res = await Notification.requestPermission();
console.debug(res);
}
} catch (e) {
console.error(e);
}
}
try {
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.ready;
if (reg && publisher) {
const api = new SnortApi(undefined, publisher);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: (await api.getPushNotificationInfo()).publicKey,
});
await api.registerPushNotifications({
endpoint: sub.endpoint,
p256dh: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
scope: `${location.protocol}//${location.hostname}`,
});
}
}
} catch (e) {
console.error(e);
}
}

View File

@ -0,0 +1,16 @@
const intl = new Intl.NumberFormat("en", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
export function formatShort(n: number) {
if (n < 2e3) {
return n;
} else if (n < 1e6) {
return `${intl.format(n / 1e3)}K`;
} else if (n < 1e9) {
return `${intl.format(n / 1e6)}M`;
} else {
return `${intl.format(n / 1e9)}G`;
}
}

View File

@ -0,0 +1,64 @@
import { unixNow } from "@snort/shared";
export enum SubscriptionType {
Supporter = 0,
Premium = 1,
}
export enum LockedFeatures {
MultiAccount = 1,
NostrAddress = 2,
Badge = 3,
DeepL = 4,
RelayRetention = 5,
RelayBackup = 6,
RelayAccess = 7,
LNProxy = 8,
EmailBridge = 9,
}
export const Plans = [
{
id: SubscriptionType.Supporter,
price: 5_000,
disabled: false,
unlocks: [
LockedFeatures.MultiAccount,
LockedFeatures.NostrAddress,
LockedFeatures.Badge,
LockedFeatures.RelayAccess,
],
},
{
id: SubscriptionType.Premium,
price: 20_000,
disabled: false,
unlocks: [
LockedFeatures.DeepL,
LockedFeatures.RelayBackup,
LockedFeatures.RelayRetention,
LockedFeatures.LNProxy,
LockedFeatures.EmailBridge,
],
},
];
export interface SubscriptionEvent {
id: string;
type: SubscriptionType;
start: number;
end: number;
}
export function getActiveSubscriptions(s: Array<SubscriptionEvent>) {
const now = unixNow();
return [...s].sort((a, b) => b.type - a.type).filter(a => a.start <= now && a.end > now);
}
export function getCurrentSubscription(s: Array<SubscriptionEvent>) {
return getActiveSubscriptions(s).at(0);
}
export function mostRecentSubscription(s: Array<SubscriptionEvent>) {
return [...s].sort((a, b) => (b.start > a.start ? -1 : 1)).at(0);
}

View File

@ -0,0 +1,84 @@
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventPublisher, EventKind } from "@snort/system";
import { UploadResult, Uploader } from "Upload";
export class Nip96Uploader implements Uploader {
constructor(
readonly url: string,
readonly publisher: EventPublisher,
) {}
get progress() {
return [];
}
async loadInfo() {
const rsp = await fetch(this.url);
return (await rsp.json()) as Nip96Info;
}
async upload(file: File | Blob, filename: string): Promise<UploadResult> {
throwIfOffline();
const auth = async (url: string, method: string) => {
const auth = await this.publisher.generic(eb => {
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
});
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
};
const info = await this.loadInfo();
const fd = new FormData();
fd.append("size", file.size.toString());
fd.append("alt", filename);
fd.append("media_type", file.type);
fd.append("file", file);
const rsp = await fetch(info.api_url, {
body: fd,
method: "POST",
headers: {
accept: "application/json",
authorization: await auth(info.api_url, "POST"),
},
});
if (rsp.ok) {
throwIfOffline();
const data = (await rsp.json()) as Nip96Result;
if (data.status === "success") {
const dim = data.nip94_event.tags
.find(a => a[0] === "dim")
?.at(1)
?.split("x");
return {
url: data.nip94_event.tags.find(a => a[0] === "url")?.at(1),
metadata: {
width: dim?.at(0) ? Number(dim[0]) : undefined,
height: dim?.at(1) ? Number(dim[1]) : undefined,
},
};
}
return {
error: data.message,
};
}
return {
error: "Upload failed",
};
}
}
export interface Nip96Info {
api_url: string;
download_url?: string;
}
export interface Nip96Result {
status: string;
message: string;
processing_url?: string;
nip94_event: {
tags: Array<Array<string>>;
content: string;
};
}

View File

@ -0,0 +1,70 @@
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher } from "@snort/system";
import { UploadResult } from "@/Utils/Upload/index";
export default async function NostrBuild(file: File | Blob, publisher?: EventPublisher): Promise<UploadResult> {
const auth = publisher
? async (url: string, method: string) => {
const auth = await publisher.generic(eb => {
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
});
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
}
: undefined;
const fd = new FormData();
fd.append("fileToUpload", file);
fd.append("submit", "Upload Image");
const url = "https://nostr.build/api/v2/upload/files";
const headers = {
accept: "application/json",
} as Record<string, string>;
if (auth) {
headers["Authorization"] = await auth(url, "POST");
}
const rsp = await fetch(url, {
body: fd,
method: "POST",
headers,
});
if (rsp.ok) {
throwIfOffline();
const data = (await rsp.json()) as NostrBuildUploadResponse;
const res = data.data[0];
return {
url: res.url,
metadata: {
blurhash: res.blurhash,
width: res.dimensions.width,
height: res.dimensions.height,
hash: res.sha256,
},
};
}
return {
error: "Upload failed",
};
}
interface NostrBuildUploadResponse {
data: Array<NostrBuildUploadData>;
}
interface NostrBuildUploadData {
input_name: string;
name: string;
url: string;
thumbnail: string;
blurhash: string;
sha256: string;
type: string;
mime: string;
size: number;
metadata: Record<string, string>;
dimensions: {
width: number;
height: number;
};
}

View File

@ -0,0 +1,43 @@
import { throwIfOffline } from "@snort/shared";
import { UploadResult } from "@/Utils/Upload/index";
export default async function NostrImg(file: File | Blob): Promise<UploadResult> {
throwIfOffline();
const fd = new FormData();
fd.append("image", file);
const rsp = await fetch("https://nostrimg.com/api/upload", {
body: fd,
method: "POST",
headers: {
accept: "application/json",
},
});
if (rsp.ok) {
const data: UploadResponse = await rsp.json();
if (typeof data?.imageUrl === "string" && data.success) {
return {
url: new URL(data.imageUrl).toString(),
};
}
}
return {
error: "Upload failed",
};
}
interface UploadResponse {
fileID?: string;
fileName?: string;
imageUrl?: string;
lightningDestination?: string;
lightningPaymentLink?: string;
message?: string;
route?: string;
status: number;
success: boolean;
url?: string;
data?: {
url?: string;
};
}

View File

@ -0,0 +1,102 @@
import { EventKind, EventPublisher } from "@snort/system";
import { UploadState, VoidApi } from "@void-cat/api";
import { FileExtensionRegex } from "@/Utils/Const";
import { UploadResult } from "@/Utils/Upload/index";
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
/**
* Upload file to void.cat
* https://void.cat/swagger/index.html
*/
export default async function VoidCatUpload(
file: File | Blob,
filename: string,
publisher?: EventPublisher,
progress?: (n: number) => void,
stage?: (n: "starting" | "hashing" | "uploading" | "done" | undefined) => void,
): Promise<UploadResult> {
throwIfOffline();
const auth = publisher
? async (url: string, method: string) => {
const auth = await publisher.generic(eb => {
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
});
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
}
: undefined;
const api = new VoidApi("https://void.cat", auth);
const uploader = api.getUploader(
file,
sx => {
stage?.(
(() => {
switch (sx) {
case UploadState.Starting:
return "starting";
case UploadState.Hashing:
return "hashing";
case UploadState.Uploading:
return "uploading";
case UploadState.Done:
return "done";
}
})(),
);
},
px => {
progress?.(px / file.size);
},
);
const rsp = await uploader.upload({
"V-Strip-Metadata": "true",
});
if (rsp.ok) {
let ext = filename.match(FileExtensionRegex);
if (rsp.file?.metadata?.mimeType === "image/webp") {
ext = ["", "webp"];
}
const resultUrl = rsp.file?.metadata?.url ?? `https://void.cat/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`;
const dim = rsp.file?.metadata?.mediaDimensions ? rsp.file.metadata.mediaDimensions.split("x") : undefined;
const ret = {
url: resultUrl,
metadata: {
hash: rsp.file?.metadata?.digest,
width: dim ? Number(dim[0]) : undefined,
height: dim ? Number(dim[1]) : undefined,
},
} as UploadResult;
if (publisher) {
// NIP-94
/*const tags = [
["url", resultUrl],
["x", rsp.file?.metadata?.digest ?? ""],
["m", rsp.file?.metadata?.mimeType ?? "application/octet-stream"],
];
if (rsp.file?.metadata?.size) {
tags.push(["size", rsp.file.metadata.size.toString()]);
}
if (rsp.file?.metadata?.magnetLink) {
tags.push(["magnet", rsp.file.metadata.magnetLink]);
const parsedMagnet = magnetURIDecode(rsp.file.metadata.magnetLink);
if (parsedMagnet?.infoHash) {
tags.push(["i", parsedMagnet?.infoHash]);
}
}
ret.header = await publisher.generic(eb => {
eb.kind(EventKind.FileHeader).content(filename);
tags.forEach(t => eb.tag(t));
return eb;
});*/
}
return ret;
} else {
return {
error: rsp.errorMessage,
};
}
}

View File

@ -0,0 +1,164 @@
import { useState } from "react";
import useLogin from "@/Hooks/useLogin";
import { NostrEvent } from "@snort/system";
import { v4 as uuid } from "uuid";
import NostrBuild from "@/Utils/Upload/NostrBuild";
import VoidCat from "@/Utils/Upload/VoidCat";
import NostrImg from "@/Utils/Upload/NostrImg";
import { KieranPubKey } from "@/Utils/Const";
import { bech32ToHex, unwrap } from "@/Utils";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { Nip96Uploader } from "./Nip96";
export interface UploadResult {
url?: string;
error?: string;
/**
* NIP-94 File Header
*/
header?: NostrEvent;
/**
* Media metadata
*/
metadata?: {
blurhash?: string;
width?: number;
height?: number;
hash?: string;
};
}
/**
* List of supported upload services and their owners on nostr
*/
export const UploaderServices = [
{
name: "void.cat",
owner: bech32ToHex(KieranPubKey),
},
{
name: "nostr.build",
owner: bech32ToHex("npub1nxy4qpqnld6kmpphjykvx2lqwvxmuxluddwjamm4nc29ds3elyzsm5avr7"),
},
{
name: "nostrimg.com",
owner: bech32ToHex("npub1xv6axulxcx6mce5mfvfzpsy89r4gee3zuknulm45cqqpmyw7680q5pxea6"),
},
];
export interface Uploader {
upload: (f: File | Blob, filename: string) => Promise<UploadResult>;
progress: Array<UploadProgress>;
}
export interface UploadProgress {
id: string;
file: File | Blob;
progress: number;
stage: UploadStage;
}
export type UploadStage = "starting" | "hashing" | "uploading" | "done" | undefined;
export default function useFileUpload(): Uploader {
const fileUploader = useLogin(s => s.appData.item.preferences.fileUploader);
const { publisher } = useEventPublisher();
const [progress, setProgress] = useState<Array<UploadProgress>>([]);
const [stage, setStage] = useState<UploadStage>();
switch (fileUploader) {
case "nostr.build": {
return {
upload: f => NostrBuild(f, publisher),
progress: [],
} as Uploader;
}
case "void.cat-NIP96": {
return new Nip96Uploader("https://void.cat/nostr", unwrap(publisher));
}
case "nostrcheck.me": {
return new Nip96Uploader("https://nostrcheck.me/api/v2/nip96", unwrap(publisher));
}
case "nostrimg.com": {
return {
upload: NostrImg,
progress: [],
} as Uploader;
}
default: {
return {
upload: async (f, n) => {
const id = uuid();
setProgress(s => [
...s,
{
id,
file: f,
progress: 0,
stage: undefined,
},
]);
const px = (n: number) => {
setProgress(s =>
s.map(v =>
v.id === id
? {
...v,
progress: n,
}
: v,
),
);
};
const ret = await VoidCat(f, n, publisher, px, s => setStage(s));
setProgress(s => s.filter(a => a.id !== id));
return ret;
},
progress,
stage,
} as Uploader;
}
}
}
export const ProgressStream = (file: File | Blob, progress: (n: number) => void) => {
let offset = 0;
const DefaultChunkSize = 1024 * 32;
const readChunk = async (offset: number, size: number) => {
if (offset > file.size) {
return new Uint8Array(0);
}
const end = Math.min(offset + size, file.size);
const blob = file.slice(offset, end, file.type);
const data = await blob.arrayBuffer();
return new Uint8Array(data);
};
const rsBase = new ReadableStream(
{
start: async () => {},
pull: async controller => {
const chunk = await readChunk(offset, controller.desiredSize ?? DefaultChunkSize);
if (chunk.byteLength === 0) {
controller.close();
return;
}
progress((offset + chunk.byteLength) / file.size);
offset += chunk.byteLength;
controller.enqueue(chunk);
},
cancel: reason => {
console.log(reason);
},
type: "bytes",
},
{
highWaterMark: DefaultChunkSize,
},
);
return rsBase;
};

View File

@ -0,0 +1,39 @@
import { magnetURIDecode, getRelayName } from ".";
import { describe, it, expect } from "vitest";
describe("magnet", () => {
it("should parse magnet link", () => {
const book =
"magnet:?xt=urn:btih:d2474e86c95b19b8bcfdb92bc12c9d44667cfa36&xt=urn:btmh:1220d2474e86c95b19b8bcfdb92bc12c9d44667cfa36d2474e86c95b19b8bcfdb92b&dn=Leaves+of+Grass+by+Walt+Whitman.epub&tr=udp%3A%2F%2Ftracker.example4.com%3A80&tr=udp%3A%2F%2Ftracker.example5.com%3A80&tr=udp%3A%2F%2Ftracker.example3.com%3A6969&tr=udp%3A%2F%2Ftracker.example2.com%3A80&tr=udp%3A%2F%2Ftracker.example1.com%3A1337";
const output = magnetURIDecode(book);
expect(output).not.toBeUndefined();
expect(output!.dn).toEqual("Leaves of Grass by Walt Whitman.epub");
expect(output!.infoHash).toEqual("d2474e86c95b19b8bcfdb92bc12c9d44667cfa36");
expect(output!.tr).toEqual([
"udp://tracker.example4.com:80",
"udp://tracker.example5.com:80",
"udp://tracker.example3.com:6969",
"udp://tracker.example2.com:80",
"udp://tracker.example1.com:1337",
]);
});
});
describe("getRelayName", () => {
it("should return relay name", () => {
const url = "wss://relay.snort.social/";
const output = getRelayName(url);
expect(output).toEqual("relay.snort.social");
});
it("should return relay name with search property", () => {
const url = "wss://relay.example1.com/?lang=en";
const output = getRelayName(url);
expect(output).toEqual("relay.example1.com?lang=en");
});
it("should return relay name without pathname", () => {
const url =
"wss://relay.example2.com/npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws?broadcast=true";
const output = getRelayName(url);
expect(output).toEqual("relay.example2.com?broadcast=true");
});
});

View File

@ -0,0 +1,186 @@
import { UserCache } from "@/Cache";
import { LNURL, ExternalStore, unixNow } from "@snort/shared";
import { Toastore } from "@/Components/Toaster/Toaster";
import { LNWallet, WalletInvoiceState, Wallets } from "@/Wallet";
import { bech32ToHex, getDisplayName, trackEvent } from "@/Utils/index";
import { SnortPubKey } from "@/Utils/Const";
export enum ZapPoolRecipientType {
Generic = 0,
Relay = 1,
FileHost = 2,
DataProvider = 3,
}
export interface ZapPoolRecipient {
type: ZapPoolRecipientType;
pubkey: string;
split: number;
sum: number;
}
class ZapPool extends ExternalStore<Array<ZapPoolRecipient>> {
#store = new Map<string, ZapPoolRecipient>();
#isPayoutInProgress = false;
#lastPayout = 0;
constructor() {
super();
this.#load();
setTimeout(() => this.#autoPayout().catch(console.error), 5_000);
}
async payout(wallet: LNWallet) {
if (this.#isPayoutInProgress) {
throw new Error("Payout already in progress");
}
this.#isPayoutInProgress = true;
this.#lastPayout = unixNow();
for (const x of this.#store.values()) {
if (x.sum === 0) continue;
try {
const profile = await UserCache.get(x.pubkey);
if (!profile) {
throw new Error(`Failed to get profile for ${x.pubkey}`);
}
const svc = new LNURL(profile.lud16 || profile.lud06 || "");
await svc.load();
const amtSend = x.sum;
const invoice = await svc.getInvoice(amtSend, `SnortZapPool: ${x.split}%`);
if (invoice.pr) {
const result = await wallet.payInvoice(invoice.pr);
console.debug("ZPC", invoice, result);
if (result.state === WalletInvoiceState.Paid) {
x.sum -= amtSend;
Toastore.push({
element: `Sent ${amtSend.toLocaleString()} sats to ${getDisplayName(
profile,
x.pubkey,
)} from your zap pool`,
expire: unixNow() + 10,
icon: "zap",
});
} else {
throw new Error(`Failed to pay invoice, unknown reason`);
}
} else {
throw new Error(invoice.reason ?? "Failed to get invoice");
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
const profile = UserCache.getFromCache(x.pubkey);
Toastore.push({
element: `Failed to send sats to ${getDisplayName(profile, x.pubkey)} (${
e.message
}), please try again later`,
expire: unixNow() + 10,
icon: "close",
});
}
}
}
this.#save();
this.notifyChange();
this.#isPayoutInProgress = false;
}
calcAllocation(n: number) {
let res = 0;
for (const x of this.#store.values()) {
res += Math.ceil(n * (x.split / 100));
}
return res;
}
allocate(n: number) {
if (this.#isPayoutInProgress) {
throw new Error("Payout is in progress, cannot allocate to pool");
}
for (const x of this.#store.values()) {
x.sum += Math.ceil(n * (x.split / 100));
}
this.#save();
this.notifyChange();
}
getOrDefault(rcpt: ZapPoolRecipient): ZapPoolRecipient {
const k = this.#key(rcpt);
const existing = this.#store.get(k);
if (existing) {
return { ...existing };
}
return rcpt;
}
set(rcpt: ZapPoolRecipient) {
const k = this.#key(rcpt);
// delete entry if split is 0 and sum is 0
if (rcpt.split === 0 && rcpt.sum === 0 && this.#store.has(k)) {
this.#store.delete(k);
} else {
this.#store.set(k, rcpt);
}
this.#save();
this.notifyChange();
}
#key(rcpt: ZapPoolRecipient) {
return `${rcpt.pubkey}-${rcpt.type}`;
}
#save() {
self.localStorage.setItem("zap-pool", JSON.stringify(this.takeSnapshot()));
self.localStorage.setItem("zap-pool-last-payout", this.#lastPayout.toString());
}
#load() {
const existing = self.localStorage.getItem("zap-pool");
if (existing) {
const arr = JSON.parse(existing) as Array<ZapPoolRecipient>;
this.#store = new Map(arr.map(a => [`${a.pubkey}-${a.type}`, a]));
} else if (CONFIG.defaultZapPoolFee) {
this.#store = new Map([
[
`${bech32ToHex(SnortPubKey)}-${ZapPoolRecipientType.Generic}`,
{
type: ZapPoolRecipientType.Generic,
split: CONFIG.defaultZapPoolFee,
pubkey: bech32ToHex(SnortPubKey),
sum: 0,
},
],
]);
}
const lastPayout = self.localStorage.getItem("zap-pool-last-payout");
if (lastPayout) {
this.#lastPayout = Number(lastPayout);
}
}
async #autoPayout() {
const payoutInterval = 60 * 60;
try {
if (this.#lastPayout < unixNow() - payoutInterval) {
const wallet = Wallets.get();
if (wallet) {
if (wallet.canAutoLogin()) {
await wallet.login();
}
trackEvent("ZapPool", { automatic: true });
await this.payout(wallet);
}
}
} catch (e) {
console.error(e);
}
setTimeout(() => this.#autoPayout().catch(console.error), 60_000);
}
takeSnapshot(): ZapPoolRecipient[] {
return [...this.#store.values()];
}
}
export const ZapPoolController = CONFIG.features.zapPool ? new ZapPool() : undefined;

View File

@ -0,0 +1,212 @@
import { LNURL, isHex } from "@snort/shared";
import { EventPublisher, NostrEvent, NostrLink, SystemInterface } from "@snort/system";
import { generateRandomKey } from "@/Utils/Login";
import { LNWallet, WalletInvoiceState } from "@/Wallet";
export interface ZapTarget {
type: "lnurl" | "pubkey";
value: string;
weight: number;
memo?: string;
name?: string;
zap?: {
pubkey: string;
anon: boolean;
event?: NostrLink;
};
}
export interface ZapTargetResult {
target: ZapTarget;
paid: boolean;
sent: number;
fee: number;
pr: string;
error?: Error;
}
interface ZapTargetLoaded {
target: ZapTarget;
svc?: LNURL;
}
export class Zapper {
#inProgress = false;
#loadedTargets?: Array<ZapTargetLoaded>;
constructor(
readonly system: SystemInterface,
readonly publisher?: EventPublisher,
readonly onResult?: (r: ZapTargetResult) => void,
) {}
/**
* Create targets from Event
*/
static fromEvent(ev: NostrEvent) {
if (ev.tags.some(a => a[0] === "zap")) {
return ev.tags
.filter(a => a[0] === "zap")
.map(v => {
if (v[1].length === 64 && isHex(v[1]) && v.length === 4) {
// NIP-57.G
return {
type: "pubkey",
value: v[1],
weight: Number(v[3] ?? 0),
zap: {
pubkey: v[1],
event: NostrLink.fromEvent(ev),
},
} as ZapTarget;
} else {
// assume event specific zap target
return {
type: "lnurl",
value: v[1],
weight: 1,
zap: {
pubkey: ev.pubkey,
event: NostrLink.fromEvent(ev),
},
} as ZapTarget;
}
});
} else {
return [
{
type: "pubkey",
value: ev.pubkey,
weight: 1,
zap: {
pubkey: ev.pubkey,
event: NostrLink.fromEvent(ev),
},
} as ZapTarget,
];
}
}
async send(wallet: LNWallet | undefined, targets: Array<ZapTarget>, amount: number) {
if (this.#inProgress) {
throw new Error("Payout already in progress");
}
this.#inProgress = true;
const total = targets.reduce((acc, v) => (acc += v.weight), 0);
const ret = [] as Array<ZapTargetResult>;
for (const t of targets) {
const toSend = Math.floor(amount * (t.weight / total));
try {
const svc = await this.#getService(t);
if (!svc) {
throw new Error(`Failed to get invoice from ${t.value}`);
}
const relays = this.system.Sockets.filter(a => !a.ephemeral).map(v => v.address);
const pub = t.zap?.anon ?? false ? EventPublisher.privateKey(generateRandomKey().privateKey) : this.publisher;
const zap =
t.zap && svc.canZap
? await pub?.zap(toSend * 1000, t.zap.pubkey, relays, t.zap?.event, t.memo, eb => {
if (t.zap?.anon) {
eb.tag(["anon", ""]);
}
return eb;
})
: undefined;
const invoice = await svc.getInvoice(toSend, t.memo, zap);
if (invoice?.pr) {
const res = await wallet?.payInvoice(invoice.pr);
ret.push({
target: t,
paid: res?.state === WalletInvoiceState.Paid,
sent: toSend,
pr: invoice.pr,
fee: res?.fees ?? 0,
});
this.onResult?.(ret[ret.length - 1]);
} else {
throw new Error(`Failed to get invoice from ${t.value}`);
}
} catch (e) {
ret.push({
target: t,
paid: false,
sent: 0,
fee: 0,
pr: "",
error: e as Error,
});
this.onResult?.(ret[ret.length - 1]);
}
}
this.#inProgress = false;
return ret;
}
async load(targets: Array<ZapTarget>) {
const svcs = targets.map(async a => {
return {
target: a,
loading: await this.#getService(a),
};
});
const loaded = await Promise.all(svcs);
this.#loadedTargets = loaded.map(a => ({
target: a.target,
svc: a.loading,
}));
}
/**
* Any target supports zaps
*/
canZap() {
return this.#loadedTargets?.some(a => a.svc?.canZap ?? false);
}
/**
* Max comment length which can be sent to all (smallest comment length)
*/
maxComment() {
return (
this.#loadedTargets
?.map(a => (a.svc?.canZap ? 255 : a.svc?.maxCommentLength ?? 0))
.reduce((acc, v) => (acc > v ? v : acc), 255) ?? 0
);
}
/**
* Max of the min amounts
*/
minAmount() {
return this.#loadedTargets?.map(a => a.svc?.min ?? 0).reduce((acc, v) => (acc < v ? v : acc), 1000) ?? 0;
}
/**
* Min of the max amounts
*/
maxAmount() {
return this.#loadedTargets?.map(a => a.svc?.max ?? 100e9).reduce((acc, v) => (acc > v ? v : acc), 100e9) ?? 0;
}
async #getService(t: ZapTarget) {
try {
if (t.type === "lnurl") {
const svc = new LNURL(t.value);
await svc.load();
return svc;
} else if (t.type === "pubkey") {
const profile = await this.system.ProfileLoader.fetchProfile(t.value);
if (profile) {
const svc = new LNURL(profile.lud16 ?? profile.lud06 ?? "");
await svc.load();
return svc;
}
}
} catch {
// nothing
}
}
}

View File

@ -0,0 +1,12 @@
import { matchSorter } from "match-sorter";
export default async function searchEmoji(key: string) {
const emoji = await import("emojilib");
/* build proper library with included name of the emoji */
const library = Object.entries(emoji).map(([emoji, keywords]) => ({
name: keywords[0],
keywords,
char: emoji,
}));
return matchSorter(library, key, { keys: ["keywords"] });
}

View File

@ -0,0 +1,564 @@
import TZ from "../tz.json";
import Nostrich from "../img/nostrich.webp";
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { sha256 as hash } from "@noble/hashes/sha256";
import { hmac } from "@noble/hashes/hmac";
import { bytesToHex } from "@noble/hashes/utils";
import { bech32, base32hex } from "@scure/base";
import {
HexKey,
TaggedNostrEvent,
u256,
EventKind,
encodeTLV,
NostrPrefix,
NostrEvent,
MetadataCache,
NostrLink,
UserMetadata,
} from "@snort/system";
import { isHex, isOffline } from "@snort/shared";
import { Birthday, Day } from "@/Utils/Const";
import AnimalName from "@/Components/User/AnimalName";
export const sha256 = (str: string | Uint8Array): u256 => {
return utils.bytesToHex(hash(str));
};
export function getPublicKey(privKey: HexKey) {
return utils.bytesToHex(secp.schnorr.getPublicKey(privKey));
}
export async function openFile(): Promise<File | undefined> {
return new Promise(resolve => {
const elm = document.createElement("input");
let lock = false;
elm.type = "file";
const handleInput = (e: Event) => {
lock = true;
const elm = e.target as HTMLInputElement;
if ((elm.files?.length ?? 0) > 0) {
resolve(elm.files![0]);
} else {
resolve(undefined);
}
};
elm.onchange = e => handleInput(e);
elm.click();
window.addEventListener(
"focus",
() => {
setTimeout(() => {
if (!lock) {
console.debug("FOCUS WINDOW UPLOAD");
resolve(undefined);
}
}, 300);
},
{ once: true },
);
});
}
/**
* Parse bech32 ids
* https://github.com/nostr-protocol/nips/blob/master/19.md
* @param id bech32 id
*/
export function parseId(id: string) {
const hrp = ["note", "npub", "nsec"];
try {
if (hrp.some(a => id.startsWith(a))) {
return bech32ToHex(id);
}
} catch (e) {
// Ignore the error.
}
return id;
}
export function bech32ToHex(str: string) {
const nKey = bech32.decode(str, 10_000);
const buff = bech32.fromWords(nKey.words);
return bytesToHex(buff);
}
/**
* Decode bech32 to string UTF-8
* @param str bech32 encoded string
* @returns
*/
export function bech32ToText(str: string) {
const nKey = bech32.decode(str, 10_000);
const buff = bech32.fromWords(nKey.words);
return new TextDecoder().decode(buff);
}
/**
* Convert hex note id to bech32 link url
* @param hex
* @returns
*/
export function eventLink(hex: u256, relays?: Array<string> | string) {
const encoded = relays
? encodeTLV(NostrPrefix.Event, hex, Array.isArray(relays) ? relays : [relays])
: hexToBech32(NostrPrefix.Note, hex);
return `/${encoded}`;
}
/**
* Convert hex to bech32
*/
export function hexToBech32(hrp: string, hex?: string) {
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0 || !isHex(hex)) {
return "";
}
try {
if (hrp === NostrPrefix.Note || hrp === NostrPrefix.PrivateKey || hrp === NostrPrefix.PublicKey) {
const buf = utils.hexToBytes(hex);
return bech32.encode(hrp, bech32.toWords(buf));
} else {
return encodeTLV(hrp as NostrPrefix, hex);
}
} catch (e) {
console.warn("Invalid hex", hex, e);
return "";
}
}
export function getLinkReactions(
notes: ReadonlyArray<TaggedNostrEvent> | undefined,
link: NostrLink,
kind?: EventKind,
) {
return notes?.filter(a => a.kind === (kind ?? a.kind) && link.isReplyToThis(a)) || [];
}
export function getAllLinkReactions(
notes: readonly TaggedNostrEvent[] | undefined,
links: Array<NostrLink>,
kind?: EventKind,
) {
return notes?.filter(a => a.kind === (kind ?? a.kind) && links.some(b => b.isReplyToThis(a))) || [];
}
export function deepClone<T>(obj: T) {
if ("structuredClone" in window) {
return structuredClone(obj);
} else {
return JSON.parse(JSON.stringify(obj));
}
}
/**
* Simple debounce
*/
export function debounce(timeout: number, fn: () => void) {
const t = setTimeout(fn, timeout);
return () => clearTimeout(t);
}
export function dedupeByPubkey(events: TaggedNostrEvent[]) {
const deduped = events.reduce(
({ list, seen }: { list: TaggedNostrEvent[]; seen: Set<HexKey> }, ev) => {
if (seen.has(ev.pubkey)) {
return { list, seen };
}
seen.add(ev.pubkey);
return {
seen,
list: [...list, ev],
};
},
{ list: [], seen: new Set([]) },
);
return deduped.list as TaggedNostrEvent[];
}
export function dedupeById<T extends { id: string }>(events: Array<T>) {
const deduped = events.reduce(
({ list, seen }: { list: Array<T>; seen: Set<string> }, ev) => {
if (seen.has(ev.id)) {
return { list, seen };
}
seen.add(ev.id);
return {
seen,
list: [...list, ev],
};
},
{ list: [], seen: new Set([]) },
);
return deduped.list as Array<T>;
}
/**
* Return newest event by pubkey
* @param events List of all notes to filter from
* @returns
*/
export function getLatestByPubkey(events: TaggedNostrEvent[]): Map<HexKey, TaggedNostrEvent> {
const deduped = events.reduce((results: Map<HexKey, TaggedNostrEvent>, ev) => {
if (!results.has(ev.pubkey)) {
const latest = getNewest(events.filter(a => a.pubkey === ev.pubkey));
if (latest) {
results.set(ev.pubkey, latest);
}
}
return results;
}, new Map<HexKey, TaggedNostrEvent>());
return deduped;
}
export function getLatestProfileByPubkey(profiles: MetadataCache[]): Map<HexKey, MetadataCache> {
const deduped = profiles.reduce((results: Map<HexKey, MetadataCache>, ev) => {
if (!results.has(ev.pubkey)) {
const latest = getNewestProfile(profiles.filter(a => a.pubkey === ev.pubkey));
if (latest) {
results.set(ev.pubkey, latest);
}
}
return results;
}, new Map<HexKey, MetadataCache>());
return deduped;
}
export function dedupe<T>(v: Array<T>) {
return [...new Set(v)];
}
export function appendDedupe<T>(a?: Array<T>, b?: Array<T>) {
return dedupe([...(a ?? []), ...(b ?? [])]);
}
export function unwrap<T>(v: T | undefined | null): T {
if (v === undefined || v === null) {
throw new Error("missing value");
}
return v;
}
export function randomSample<T>(coll: T[], size: number) {
const random = [...coll];
return random.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, size);
}
export function getNewest(rawNotes: readonly TaggedNostrEvent[]) {
const notes = [...rawNotes];
notes.sort((a, b) => b.created_at - a.created_at);
if (notes.length > 0) {
return notes[0];
}
}
export function getNewestProfile(rawNotes: MetadataCache[]) {
const notes = [...rawNotes];
notes.sort((a, b) => b.created - a.created);
if (notes.length > 0) {
return notes[0];
}
}
export function getNewestEventTagsByKey(evs: TaggedNostrEvent[], tag: string) {
const newest = getNewest(evs);
if (newest) {
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === tag).map(p => p[1]);
return {
keys,
createdAt: newest.created_at,
};
}
}
export function tagFilterOfTextRepost(note: TaggedNostrEvent, id?: u256): (tag: string[], i: number) => boolean {
return (tag, i) =>
tag[0] === "e" && tag[3] === "mention" && note.content === `#[${i}]` && (id ? tag[1] === id : true);
}
export function groupByPubkey(acc: Record<HexKey, MetadataCache>, user: MetadataCache) {
return { ...acc, [user.pubkey]: user };
}
export const delay = (t: number) => {
return new Promise(resolve => {
setTimeout(resolve, t);
});
};
export function orderDescending<T>(arr: Array<T & { created_at: number }>) {
return arr.sort((a, b) => (b.created_at > a.created_at ? 1 : -1));
}
export function orderAscending<T>(arr: Array<T & { created_at: number }>) {
return arr.sort((a, b) => (b.created_at > a.created_at ? -1 : 1));
}
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 = base32hex.decode(m[1]);
result.infoHash = [bytesToHex(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);
}
}
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: NostrEvent, tag: string) {
const maybeTag = e.tags.find(evTag => {
return evTag[0] === tag;
});
return maybeTag && maybeTag[1];
}
export function hmacSha256(key: Uint8Array, ...messages: Uint8Array[]) {
return hmac(hash, key, utils.concatBytes(...messages));
}
export function getRelayName(url: string) {
const parsedUrl = new URL(url);
return parsedUrl.host + parsedUrl.search;
}
export function getUrlHostname(url?: string) {
try {
return new URL(url ?? "").hostname;
} catch {
return url?.match(/(\S+\.\S+)/i)?.[1] ?? url;
}
}
export function sanitizeRelayUrl(url: string) {
try {
return new URL(url).toString();
} catch {
// ignore
}
}
export function kvToObject<T>(o: string, sep?: string) {
return Object.fromEntries(
o.split(sep ?? ",").map(v => {
const match = v.trim().match(/^(\w+)="(.*)"$/);
if (match) {
return [match[1], match[2]];
}
return [];
}),
) as T;
}
export function defaultAvatar(input?: string) {
if (isOffline()) return Nostrich;
return `https://robohash.v0l.io/${input ?? "missing"}.png${isHalloween() ? "?set=set2" : ""}`;
}
export function isFormElement(target: HTMLElement): boolean {
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
return true;
}
return false;
}
const ThisYear = new Date().getFullYear();
const IsTheSeason = (target: Date, window: number) => {
const now = new Date();
const days = (target.getTime() - now.getTime()) / (Day * 1000);
return (days >= 0 && days <= window) || (now.getDate() === target.getDate() && now.getMonth() === target.getMonth());
};
export const isHalloween = () => {
const event = new Date(ThisYear, 9, 31);
return IsTheSeason(event, 7);
};
export const isStPatricksDay = () => {
const event = new Date(ThisYear, 2, 17);
return IsTheSeason(event, 1);
};
export const isChristmas = () => {
const event = new Date(ThisYear, 11, 25);
return IsTheSeason(event, 7);
};
export const isBirthday = () => {
const event = new Date(ThisYear, Birthday.getMonth(), Birthday.getDate());
return CONFIG.appName === "Snort" && IsTheSeason(event, 1);
};
export function getDisplayName(user: UserMetadata | undefined, pubkey: HexKey): string {
return getDisplayNameOrPlaceHolder(user, pubkey)[0];
}
export function getDisplayNameOrPlaceHolder(user: UserMetadata | undefined, pubkey: HexKey): [string, boolean] {
let name = hexToBech32(NostrPrefix.PublicKey, pubkey).substring(0, 12);
let isPlaceHolder = false;
if (typeof user?.display_name === "string" && user.display_name.length > 0) {
name = user.display_name;
} else if (typeof user?.name === "string" && user.name.length > 0) {
name = user.name;
} else if (pubkey && CONFIG.animalNamePlaceholders) {
name = AnimalName(pubkey);
isPlaceHolder = true;
}
return [name.trim(), isPlaceHolder];
}
export function getCountry() {
const tz = Intl.DateTimeFormat().resolvedOptions();
const info = (TZ as Record<string, Array<string> | undefined>)[tz.timeZone];
const pos = info?.[1];
const sep = Number(pos?.slice(1).search(/[-+]/)) + 1;
const [lat, lon] = [pos?.slice(0, sep) ?? "00", pos?.slice(sep) ?? "000"];
return {
zone: tz.timeZone,
country: info?.[0],
lat: Number(lat) / Math.pow(10, lat.length - 3),
lon: Number(lon) / Math.pow(10, lon.length - 4),
info,
};
}
export function trackEvent(event: string, props?: Record<string, string | boolean>) {
window.plausible?.(event, props ? { props } : undefined);
}
export function storeRefCode() {
const ref = getCurrentRefCode();
if (ref) {
window.localStorage.setItem("ref", ref);
}
}
export function getCurrentRefCode() {
if (window.location.search) {
const q = new URLSearchParams(window.location.search);
const ref = q.get("ref");
if (ref) {
return ref;
}
}
}
export function getRefCode() {
const r = window.localStorage.getItem("ref");
if (r) return r;
}
export function deleteRefCode() {
window.localStorage.removeItem("ref");
}

View File

@ -0,0 +1,37 @@
import * as utils from "@noble/curves/abstract/utils";
import * as bip39 from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";
import { HDKey } from "@scure/bip32";
import { DerivationPath } from "@/Utils/Const";
export function generateBip39Entropy(mnemonic?: string): Uint8Array {
try {
const mn = mnemonic ?? bip39.generateMnemonic(wordlist, 256);
return bip39.mnemonicToEntropy(mn, wordlist);
} catch (e) {
throw new Error("INVALID MNEMONIC PHRASE");
}
}
/**
* Convert hex-encoded entropy into mnemonic phrase
*/
export function hexToMnemonic(hex: string): string {
const bytes = utils.hexToBytes(hex);
return bip39.entropyToMnemonic(bytes, wordlist);
}
/**
* Derrive NIP-06 private key from master key
*/
export function entropyToPrivateKey(entropy: Uint8Array): string {
const masterKey = HDKey.fromMasterSeed(entropy);
const newKey = masterKey.derive(DerivationPath);
if (!newKey.privateKey) {
throw new Error("INVALID KEY DERIVATION");
}
return utils.bytesToHex(newKey.privateKey);
}

View File

@ -0,0 +1,44 @@
import {
compress,
expand_filter,
flat_merge,
get_diff,
pow,
schnorr_verify_event,
default as wasmInit,
} from "../../../system-wasm/pkg/system_wasm";
import WasmPath from "../../../system-wasm/pkg/system_wasm_bg.wasm";
import { FlatReqFilter, NostrEvent, Optimizer, PowMiner, PowWorker, ReqFilter } from "@snort/system";
import PowWorkerURL from "@snort/system/src/pow-worker.ts?worker&url";
import { unwrap } from "@/Utils/index";
export const WasmOptimizer = {
expandFilter: (f: ReqFilter) => {
return expand_filter(f) as Array<FlatReqFilter>;
},
getDiff: (prev: Array<ReqFilter>, next: Array<ReqFilter>) => {
return get_diff(prev, next) as Array<FlatReqFilter>;
},
flatMerge: (all: Array<FlatReqFilter>) => {
return flat_merge(all) as Array<ReqFilter>;
},
compress: (all: Array<ReqFilter>) => {
return compress(all) as Array<ReqFilter>;
},
schnorrVerify: ev => {
return schnorr_verify_event(ev);
},
} as Optimizer;
export class WasmPowWorker implements PowMiner {
minePow(ev: NostrEvent, target: number): Promise<NostrEvent> {
const res = pow(ev, target);
return Promise.resolve(res);
}
}
export { wasmInit, WasmPath };
export const hasWasm = "WebAssembly" in globalThis;
const DefaultPowWorker = hasWasm ? undefined : new PowWorker(PowWorkerURL);
export const GetPowWorker = () => (hasWasm ? new WasmPowWorker() : unwrap(DefaultPowWorker));