diff --git a/src/Const.js b/src/Const.js index 7bc0410f..8139883a 100644 --- a/src/Const.js +++ b/src/Const.js @@ -41,6 +41,7 @@ export const RecommendedFollows = [ "1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha + "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol ]; /** diff --git a/src/element/AsyncButton.tsx b/src/element/AsyncButton.tsx new file mode 100644 index 00000000..f6ed67ac --- /dev/null +++ b/src/element/AsyncButton.tsx @@ -0,0 +1,27 @@ +import { useState } from "react" + +export default function AsyncButton(props: any) { + const [loading, setLoading] = useState(false); + + async function handle(e : any) { + if(loading) return; + setLoading(true); + try { + if (typeof props.onClick === "function") { + let f = props.onClick(e); + if (f instanceof Promise) { + await f; + } + } + } + finally { + setLoading(false); + } + } + + return ( +
handle(e)}> + {props.children} +
+ ) +} \ No newline at end of file diff --git a/src/element/LNURLTip.js b/src/element/LNURLTip.js index e5bf3ae5..08b10b8d 100644 --- a/src/element/LNURLTip.js +++ b/src/element/LNURLTip.js @@ -19,7 +19,7 @@ export default function LNURLTip(props) { const [success, setSuccess] = useState(null); useEffect(() => { - if (show && invoice === null) { + if (show && !props.invoice) { loadService() .then(a => setPayService(a)) .catch(() => setError("Failed to load LNURL service")); @@ -204,7 +204,7 @@ export default function LNURLTip(props) { return ( onClose()}>
e.stopPropagation()}> -

⚡️ Send sats

+

{props.title || "⚡️ Send sats"}

{invoiceForm()} {error ?

{error}

: null} {payInvoice()} diff --git a/src/element/Nip5Service.tsx b/src/element/Nip5Service.tsx new file mode 100644 index 00000000..b3b2c995 --- /dev/null +++ b/src/element/Nip5Service.tsx @@ -0,0 +1,195 @@ +import { useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { + ServiceProvider, + ServiceConfig, + ServiceError, + HandleAvailability, + ServiceErrorCode, + HandleRegisterResponse, + CheckRegisterResponse +} from "../nip05/ServiceProvider"; +import AsyncButton from "./AsyncButton"; +// @ts-ignore +import LNURLTip from "./LNURLTip"; +// @ts-ignore +import Copy from "./Copy"; +// @ts-ignore +import useProfile from "../feed/ProfileFeed"; +// @ts-ignore +import useEventPublisher from "../feed/EventPublisher"; +// @ts-ignore +import { resetProfile } from "../state/Users"; +// @ts-ignore +import { hexToBech32 } from "../Util"; + +type Nip05ServiceProps = { + name: string, + service: URL | string, + about: JSX.Element, + link: string, + supportLink: string +}; + +type ReduxStore = any; + +export default function Nip5Service(props: Nip05ServiceProps) { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const pubkey = useSelector(s => s.login.publicKey); + const user: any = useProfile(pubkey); + const publisher = useEventPublisher(); + const svc = new ServiceProvider(props.service); + const [serviceConfig, setServiceConfig] = useState(); + const [error, setError] = useState(); + const [handle, setHandle] = useState(""); + const [domain, setDomain] = useState(""); + const [availabilityResponse, setAvailabilityResponse] = useState(); + const [registerResponse, setRegisterResponse] = useState(); + const [showInvoice, setShowInvoice] = useState(false); + const [registerStatus, setRegisterStatus] = useState(); + + const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain]); + + useEffect(() => { + svc.GetConfig() + .then(a => { + if ('error' in a) { + setError(a as ServiceError) + } else { + let svc = a as ServiceConfig; + setServiceConfig(svc); + let defaultDomain = svc.domains.find(a => a.default)?.name || svc.domains[0].name; + setDomain(defaultDomain); + } + }) + .catch(console.error) + }, [props]); + + useEffect(() => { + if (handle.length === 0) { + setAvailabilityResponse(undefined); + } + if (handle && domain) { + let rx = new RegExp(domainConfig?.regex[0] ?? "", domainConfig?.regex[1] ?? ""); + if (!rx.test(handle)) { + setAvailabilityResponse({ available: false, why: "REGEX" }); + return; + } + let t = setTimeout(() => { + svc.CheckAvailable(handle, domain) + .then(a => { + if ('error' in a) { + setError(a as ServiceError); + } else { + setAvailabilityResponse(a as HandleAvailability); + } + }) + .catch(console.error); + }, 500); + return () => clearTimeout(t); + } + }, [handle, domain]); + + useEffect(() => { + if (registerResponse && showInvoice) { + let t = setInterval(async () => { + let status = await svc.CheckRegistration(registerResponse.token); + if ('error' in status) { + setError(status); + setRegisterResponse(undefined); + setShowInvoice(false); + } else { + let result: CheckRegisterResponse = status; + if (result.available && result.paid) { + setShowInvoice(false); + setRegisterStatus(status); + setRegisterResponse(undefined); + setError(undefined); + } + } + }, 2_000); + return () => clearInterval(t); + } + }, [registerResponse, showInvoice]) + + function mapError(e: ServiceErrorCode, t: string | null): string | undefined { + let whyMap = new Map([ + ["TOO_SHORT", "name too short"], + ["TOO_LONG", "name too long"], + ["REGEX", "name has disallowed characters"], + ["REGISTERED", "name is registered"], + ["DISALLOWED_null", "name is blocked"], + ["DISALLOWED_later", "name will be available later"], + ]); + return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e); + } + + async function startBuy(handle: string, domain: string) { + if (registerResponse) { + setShowInvoice(true); + return; + } + + let rsp = await svc.RegisterHandle(handle, domain, pubkey); + if ('error' in rsp) { + setError(rsp); + } else { + setRegisterResponse(rsp); + setShowInvoice(true); + } + } + + async function updateProfile(handle: string, domain: string) { + let newProfile = { + ...user, + nip05: `${handle}@${domain}` + }; + delete newProfile["loaded"]; + delete newProfile["fromEvent"]; + delete newProfile["pubkey"]; + let ev = await publisher.metadata(newProfile); + dispatch(resetProfile(pubkey)); + publisher.broadcast(ev); + navigate("/settings"); + } + + return ( + <> +

{props.name}

+ {props.about} +

Find out more info about {props.name} at {props.link}

+ {error && {error.error}} + {!registerStatus &&
+ setHandle(e.target.value)} /> +  @  + +
} + {availabilityResponse?.available && !registerStatus &&
+
+ {availabilityResponse.quote?.price.toLocaleString()} sats
+ {availabilityResponse.quote?.data.type} +
+ + startBuy(handle, domain)}>Buy Now +
} + {availabilityResponse?.available === false && !registerStatus &&
+ Not available: {mapError(availabilityResponse.why!, availabilityResponse.reasonTag || null)} +
} + setShowInvoice(false)} title={`Buying ${handle}@${domain}`} /> + {registerStatus?.paid &&
+

Order Paid!

+

Your new NIP-05 handle is: {handle}@{domain}

+

Account Support

+

Please make sure to save the following password in order to manage your handle in the future

+ +

Go to account page

+

Activate Now

+ updateProfile(handle, domain)}>Add to Profile +
} + + ) +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index bc2e998f..9161c163 100644 --- a/src/index.css +++ b/src/index.css @@ -91,6 +91,10 @@ code { font-weight: bold; } +.btn.disabled { + color: var(--gray-light); +} + .btn:hover { background-color: var(--gray); } @@ -107,7 +111,7 @@ textarea { font: inherit; } -input[type="text"], input[type="password"], input[type="number"], textarea { +input[type="text"], input[type="password"], input[type="number"], textarea, select { padding: 10px; border-radius: 5px; border: 0; @@ -115,6 +119,11 @@ input[type="text"], input[type="password"], input[type="number"], textarea { color: var(--font-color); } +input:disabled { + color: var(--gray-medium); + cursor:not-allowed; +} + textarea:placeholder { color: var(--gray-superlight); } diff --git a/src/index.js b/src/index.js index 0dcaeb9e..10ec5f17 100644 --- a/src/index.js +++ b/src/index.js @@ -18,7 +18,8 @@ import Store from "./state/Store"; import NotificationsPage from './pages/Notifications'; import NewUserPage from './pages/NewUserPage'; import SettingsPage from './pages/SettingsPage'; -import ErrorPage from './pages/ErrorPage.tsx'; +import ErrorPage from './pages/ErrorPage'; +import VerificationPage from './pages/Verification'; /** * Nostr websocket managment system @@ -57,6 +58,10 @@ const router = createBrowserRouter([ { path: "/settings", element: + }, + { + path: "/verification", + element: } ] } diff --git a/src/nip05/ServiceProvider.ts b/src/nip05/ServiceProvider.ts new file mode 100644 index 00000000..11d5e45a --- /dev/null +++ b/src/nip05/ServiceProvider.ts @@ -0,0 +1,99 @@ +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"; + +export interface ServiceError { + error: ServiceErrorCode +}; + +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 { + return await this._GetJson("/config.json"); + } + + async CheckAvailable(handle: string, domain: string): Promise { + return await this._GetJson("/registration/availability", "POST", { name: handle, domain }); + } + + async RegisterHandle(handle: string, domain: string, pubkey: string): Promise { + return await this._GetJson("/registration/register", "PUT", { + name: handle, + domain, + pk: pubkey, + ref: "snort" + }); + } + + async CheckRegistration(token: string): Promise { + return await this._GetJson("/registration/register/check", "POST", undefined, { + authorization: token + }); + } + async _GetJson(path: string, method?: "GET" | string, body?: any, headers?: any): Promise { + try { + let rsp = await fetch(`${this.url}${path}`, { + method: method, + body: body ? JSON.stringify(body) : undefined, + headers: { + accept: "application/json", + ...(body ? { "content-type": "application/json" } : {}), + ...headers + } + }); + + let obj = await rsp.json(); + if ('error' in obj) { + return obj; + } + return obj; + } catch (e) { + console.warn(e); + } + return { error: "UNKNOWN_ERROR" }; + } +} \ No newline at end of file diff --git a/src/nostr/Connection.js b/src/nostr/Connection.js index a8931341..efd1a5c1 100644 --- a/src/nostr/Connection.js +++ b/src/nostr/Connection.js @@ -70,7 +70,11 @@ export default class Connection { for (let p of this.Pending) { this._SendJson(p); } + this.Pending = []; + for (let s of Object.values(this.Subscriptions)) { + this._SendSubscription(s, s.ToObject()); + } this._UpdateState(); } @@ -159,15 +163,7 @@ export default class Connection { return; } - let req = ["REQ", sub.Id, subObj]; - if (sub.OrSubs.length > 0) { - req = [ - ...req, - ...sub.OrSubs.map(o => o.ToObject()) - ]; - } - sub.Started[this.Address] = new Date().getTime(); - this._SendJson(req); + this._SendSubscription(sub, subObj); this.Subscriptions[sub.Id] = sub; } @@ -227,6 +223,18 @@ export default class Connection { } } + _SendSubscription(sub, subObj) { + let req = ["REQ", sub.Id, subObj]; + if (sub.OrSubs.length > 0) { + req = [ + ...req, + ...sub.OrSubs.map(o => o.ToObject()) + ]; + } + sub.Started[this.Address] = new Date().getTime(); + this._SendJson(req); + } + _SendJson(obj) { if (this.Socket?.readyState !== WebSocket.OPEN) { this.Pending.push(obj); diff --git a/src/pages/ErrorPage.tsx b/src/pages/ErrorPage.tsx index 6ccee610..90c431b8 100644 --- a/src/pages/ErrorPage.tsx +++ b/src/pages/ErrorPage.tsx @@ -1,15 +1,14 @@ -import React, { FC } from "react"; import { useRouteError } from "react-router-dom"; -const ErrorPage: FC = () => { +const ErrorPage = () => { const error = useRouteError(); console.error(error); return ( <> -

{error?.message ?? "Uknown error"}

+

An error has occured!

-                {JSON.stringify(error)}
+                {JSON.stringify(error, undefined, '  ')}
             
); diff --git a/src/pages/SettingsPage.js b/src/pages/SettingsPage.js index bbc6ed9b..ffb0e3b8 100644 --- a/src/pages/SettingsPage.js +++ b/src/pages/SettingsPage.js @@ -4,6 +4,8 @@ import Nostrich from "../nostrich.jpg"; import { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faShop } from "@fortawesome/free-solid-svg-icons"; import useEventPublisher from "../feed/EventPublisher"; import useProfile from "../feed/ProfileFeed"; @@ -130,7 +132,12 @@ export default function SettingsPage(props) {
NIP-05:
- setNip05(e.target.value)} /> + setNip05(e.target.value)} /> +
navigate("/verification")}> + +   + Buy +
diff --git a/src/pages/Verification.css b/src/pages/Verification.css new file mode 100644 index 00000000..22ce987d --- /dev/null +++ b/src/pages/Verification.css @@ -0,0 +1,3 @@ +.verification a { + color: var(--highlight); +} diff --git a/src/pages/Verification.tsx b/src/pages/Verification.tsx new file mode 100644 index 00000000..4d21caca --- /dev/null +++ b/src/pages/Verification.tsx @@ -0,0 +1,40 @@ +import Nip5Service from "../element/Nip5Service"; + +import './Verification.css' + +export default function VerificationPage() { + const services = [ + /*{ + name: "Snort", + service: "https://api.snort.social/api/v1/n5sp", + link: "https://snort.social/", + about: <>Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site! + },*/ + { + name: "Nostr Plebs", + service: "https://nostrplebs.com/api/v1", + link: "https://nostrplebs.com/", + supportLink: "https://nostrplebs.com/manage", + about: <> +

Nostr Plebs is one of the first NIP-05 providers in the space and offers a good collection of domains at reasonable prices

+ + } + ]; + + return ( +
+

Get Verified

+

+ NIP-05 is a DNS based verification spec which helps to validate you as a real user. +

+

Getting NIP-05 verified can help:

+
    +
  • Prevent fake accounts from immitating you
  • +
  • Make your profile easier to find and share
  • +
  • Fund developers and platforms providing NIP-05 verification services
  • +
+ + {services.map(a => )} +
+ ) +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..5ecf8630 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "moduleResolution": "node", + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} \ No newline at end of file