reorganize code into smaller files & dirs
This commit is contained in:
162
packages/app/src/Utils/Const.ts
Normal file
162
packages/app/src/Utils/Const.ts
Normal 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;
|
259
packages/app/src/Utils/Login/Functions.ts
Normal file
259
packages/app/src/Utils/Login/Functions.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
134
packages/app/src/Utils/Login/LoginSession.ts
Normal file
134
packages/app/src/Utils/Login/LoginSession.ts
Normal 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;
|
||||
}
|
327
packages/app/src/Utils/Login/MultiAccountStore.ts
Normal file
327
packages/app/src/Utils/Login/MultiAccountStore.ts
Normal 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();
|
||||
}
|
||||
}
|
48
packages/app/src/Utils/Login/Nip7OsSigner.ts
Normal file
48
packages/app/src/Utils/Login/Nip7OsSigner.ts
Normal 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);
|
||||
}
|
||||
}
|
119
packages/app/src/Utils/Login/Preferences.ts
Normal file
119
packages/app/src/Utils/Login/Preferences.ts
Normal 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;
|
21
packages/app/src/Utils/Login/index.ts
Normal file
21
packages/app/src/Utils/Login/index.ts
Normal 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";
|
126
packages/app/src/Utils/Nip05/ServiceProvider.ts
Normal file
126
packages/app/src/Utils/Nip05/ServiceProvider.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
77
packages/app/src/Utils/Nip05/SnortServiceProvider.ts
Normal file
77
packages/app/src/Utils/Nip05/SnortServiceProvider.ts
Normal 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))}`,
|
||||
});
|
||||
}
|
||||
}
|
26
packages/app/src/Utils/Nip05/Verifier.ts
Normal file
26
packages/app/src/Utils/Nip05/Verifier.ts
Normal 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;
|
||||
}
|
107
packages/app/src/Utils/Notifications.ts
Normal file
107
packages/app/src/Utils/Notifications.ts
Normal 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);
|
||||
}
|
||||
}
|
16
packages/app/src/Utils/Number.ts
Normal file
16
packages/app/src/Utils/Number.ts
Normal 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`;
|
||||
}
|
||||
}
|
64
packages/app/src/Utils/Subscription/index.ts
Normal file
64
packages/app/src/Utils/Subscription/index.ts
Normal 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);
|
||||
}
|
84
packages/app/src/Utils/Upload/Nip96.ts
Normal file
84
packages/app/src/Utils/Upload/Nip96.ts
Normal 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;
|
||||
};
|
||||
}
|
70
packages/app/src/Utils/Upload/NostrBuild.ts
Normal file
70
packages/app/src/Utils/Upload/NostrBuild.ts
Normal 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;
|
||||
};
|
||||
}
|
43
packages/app/src/Utils/Upload/NostrImg.ts
Normal file
43
packages/app/src/Utils/Upload/NostrImg.ts
Normal 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;
|
||||
};
|
||||
}
|
102
packages/app/src/Utils/Upload/VoidCat.ts
Normal file
102
packages/app/src/Utils/Upload/VoidCat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
164
packages/app/src/Utils/Upload/index.ts
Normal file
164
packages/app/src/Utils/Upload/index.ts
Normal 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;
|
||||
};
|
39
packages/app/src/Utils/Utils.test.ts
Normal file
39
packages/app/src/Utils/Utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
186
packages/app/src/Utils/ZapPoolController.ts
Normal file
186
packages/app/src/Utils/ZapPoolController.ts
Normal 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;
|
212
packages/app/src/Utils/Zapper.ts
Normal file
212
packages/app/src/Utils/Zapper.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
12
packages/app/src/Utils/emoji-search.ts
Normal file
12
packages/app/src/Utils/emoji-search.ts
Normal 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"] });
|
||||
}
|
564
packages/app/src/Utils/index.ts
Normal file
564
packages/app/src/Utils/index.ts
Normal 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");
|
||||
}
|
37
packages/app/src/Utils/nip6.ts
Normal file
37
packages/app/src/Utils/nip6.ts
Normal 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);
|
||||
}
|
44
packages/app/src/Utils/wasm.ts
Normal file
44
packages/app/src/Utils/wasm.ts
Normal 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));
|
Reference in New Issue
Block a user