diff --git a/packages/app/src/Const.ts b/packages/app/src/Const.ts index 2eefd906..0af7a091 100644 --- a/packages/app/src/Const.ts +++ b/packages/app/src/Const.ts @@ -15,6 +15,11 @@ export const Day = Hour * 24; */ export const ApiHost = "https://api.snort.social"; +/** + * Iris api for free nip05 names + */ +export const IrisHost = "https://api.iris.to"; + /** * LibreTranslate endpoint */ diff --git a/packages/app/src/Element/IrisAccount/AccountName.tsx b/packages/app/src/Element/IrisAccount/AccountName.tsx new file mode 100644 index 00000000..77d494ac --- /dev/null +++ b/packages/app/src/Element/IrisAccount/AccountName.tsx @@ -0,0 +1,31 @@ +import {useNavigate} from "react-router-dom"; + +export default function AccountName({ name = '', link = true }) { + const navigate = useNavigate(); + return ( + <> +
+ Username: {name} +
+
+ Short link:{' '} + {link ? ( + { + e.preventDefault(); + navigate(`/${name}`); + }} + > + iris.to/{name} + + ) : ( + <>iris.to/{name} + )} +
+
+ Nostr address (nip05): {name}@iris.to +
+ + ); +} diff --git a/packages/app/src/Element/IrisAccount/ActiveAccount.tsx b/packages/app/src/Element/IrisAccount/ActiveAccount.tsx new file mode 100644 index 00000000..f237f86b --- /dev/null +++ b/packages/app/src/Element/IrisAccount/ActiveAccount.tsx @@ -0,0 +1,73 @@ +import AccountName from './AccountName'; +import useLogin from "../../Hooks/useLogin"; +import {useUserProfile} from "@snort/system-react"; +import {System} from "../../index"; +import {UserCache} from "../../Cache"; +import useEventPublisher from "../../Hooks/useEventPublisher"; +import {mapEventToProfile} from "@snort/system"; + +export default function ActiveAccount({ name = '', setAsPrimary = () => {} }) { + const { publicKey, readonly } = useLogin(s => ({ + publicKey: s.publicKey, + readonly: s.readonly, + })); + const profile = useUserProfile(publicKey); + const publisher = useEventPublisher(); + + async function saveProfile(nip05: string) { + if (readonly) { + return; + } + // copy user object and delete internal fields + const userCopy = { + ...profile, + nip05, + } as Record; + delete userCopy["loaded"]; + delete userCopy["created"]; + delete userCopy["pubkey"]; + delete userCopy["npub"]; + delete userCopy["deleted"]; + delete userCopy["zapService"]; + delete userCopy["isNostrAddressValid"]; + console.debug(userCopy); + + if (publisher) { + const ev = await publisher.metadata(userCopy); + System.BroadcastEvent(ev); + + const newProfile = mapEventToProfile(ev); + if (newProfile) { + await UserCache.update(newProfile); + } + } + } + + const onClick = () => { + const newNip = name + '@iris.to'; + const timeout = setTimeout(() => { + saveProfile(newNip); + }, 2000); + if (profile) { + clearTimeout(timeout); + if (profile.nip05 !== newNip) { + saveProfile(newNip); + setAsPrimary(); + } + } + }; + + return ( +
+
+ You have an active iris.to account: + +
+

+ +

+
+ ); +} diff --git a/packages/app/src/Element/IrisAccount/IrisAccount.tsx b/packages/app/src/Element/IrisAccount/IrisAccount.tsx new file mode 100644 index 00000000..1ea18a2f --- /dev/null +++ b/packages/app/src/Element/IrisAccount/IrisAccount.tsx @@ -0,0 +1,304 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Component, FormEvent} from 'react'; +import { LoginStore } from "Login"; + +import AccountName from './AccountName'; +import ActiveAccount from './ActiveAccount'; +import ReservedAccount from './ReservedAccount'; +//import {ProfileLoader} from "../../index"; + +declare global { + interface Window { + cf_turnstile_callback: any; + } +} + +// TODO split into smaller components +export default class IrisAccount extends Component { + state = { + irisToActive: false, + existing: null as any, + profile: null as any, + newUserName: '', + newUserNameValid: false, + error: null as any, + showChallenge: false, + invalidUsernameMessage: null as any, + }; + + render() { + let view: any; + + if (this.state.irisToActive) { + const username = this.state.profile.nip05.split('@')[0]; + view = ; + } else if (this.state.existing && this.state.existing.confirmed) { + view = ( + this.setState({irisToActive: true})} + /> + ); + } else if (this.state.existing) { + view = ( + this.enableReserved()} + declineReserved={() => this.declineReserved()} + /> + ); + } else if (this.state.error) { + view =
Error: {this.state.error}
; + } else if (this.state.showChallenge) { + window.cf_turnstile_callback = (token: any) => this.register(token); + view = ( + <> +
+ + ); + } else { + view = ( +
+

Register an Iris username (iris.to/username)

+
this.showChallenge(e)}> +
+ this.onNewUserNameChange(e)} + /> + +
+
+ {this.state.newUserNameValid ? ( + <> + Username is available + + + ) : ( + {this.state.invalidUsernameMessage} + )} +
+
+
+ ); + } + + return ( + <> +

Iris.to account

+ {view} +

+ FAQ +

+ + ); + } + + async onNewUserNameChange(e: any) { + const newUserName = e.target.value; + if (newUserName.length === 0) { + this.setState({ + newUserName, + newUserNameValid: false, + invalidUsernameMessage: '', + }); + return; + } + if (newUserName.length < 8 || newUserName.length > 15) { + this.setState({ + newUserName, + newUserNameValid: false, + invalidUsernameMessage: 'Username must be between 8 and 15 characters', + }); + return; + } + if (!newUserName.match(/^[a-z0-9_.]+$/)) { + this.setState({ + newUserName, + newUserNameValid: false, + invalidUsernameMessage: 'Username must only contain lowercase letters and numbers', + }); + return; + } + this.setState({ + newUserName, + invalidUsernameMessage: '', + }); + this.checkAvailabilityFromAPI(newUserName); + } + + checkAvailabilityFromAPI = async (name: string) => { + const res = await fetch(`https://api.iris.to/user/available?name=${encodeURIComponent(name)}`); + if (name !== this.state.newUserName) { + return; + } + if (res.status < 500) { + const json = await res.json(); + if (json.available) { + this.setState({newUserNameValid: true}); + } else { + this.setState({ + newUserNameValid: false, + invalidUsernameMessage: json.message, + }); + } + } else { + this.setState({ + newUserNameValid: false, + invalidUsernameMessage: 'Error checking username availability', + }); + } + } + + showChallenge(e: FormEvent) { + e.preventDefault(); + if (!this.state.newUserNameValid) { + return; + } + this.setState({ showChallenge: true }, () => { + // Dynamically injecting Cloudflare script + if (!document.querySelector('script[src="https://challenges.cloudflare.com/turnstile/v0/api.js"]')) { + const script = document.createElement("script"); + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + script.async = true; + script.defer = true; + document.body.appendChild(script); + } + }); + } + + async register(cfToken: any) { + console.log('register', cfToken); + const login = LoginStore.snapshot(); + const publisher = LoginStore.getPublisher(login.id) + const event = await publisher?.note(`iris.to/${this.state.newUserName}`); + // post signed event as request body to https://api.iris.to/user/confirm_user + const res = await fetch('https://api.iris.to/user/signup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ event, cfToken }), + }); + if (res.status === 200) { + this.setState({ + error: null, + existing: { + confirmed: true, + name: this.state.newUserName, + }, + }); + delete window.cf_turnstile_callback; + } else { + res + .json() + .then((json) => { + this.setState({ error: json.message || 'error' }); + }) + .catch(() => { + this.setState({ error: 'error' }); + }); + } + } + + async enableReserved() { + const login = LoginStore.snapshot(); + const publisher = LoginStore.getPublisher(login.id) + const event = await publisher?.note(`iris.to/${this.state.newUserName}`); + // post signed event as request body to https://api.iris.to/user/confirm_user + const res = await fetch('https://api.iris.to/user/confirm_user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + }); + if (res.status === 200) { + this.setState({ + error: null, + existing: { confirmed: true, name: this.state.existing.name }, + }); + } else { + res + .json() + .then((json) => { + this.setState({ error: json.message || 'error' }); + }) + .catch(() => { + this.setState({ error: 'error' }); + }); + } + } + + async declineReserved() { + if (!confirm(`Are you sure you want to decline iris.to/${name}?`)) { + return; + } + const login = LoginStore.snapshot(); + const publisher = LoginStore.getPublisher(login.id) + const event = await publisher?.note(`decline iris.to/${this.state.newUserName}`); + // post signed event as request body to https://api.iris.to/user/confirm_user + const res = await fetch('https://api.iris.to/user/decline_user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + }); + if (res.status === 200) { + this.setState({ confirmSuccess: false, error: null, existing: null }); + } else { + res + .json() + .then((json) => { + this.setState({ error: json.message || 'error' }); + }) + .catch(() => { + this.setState({ error: 'error' }); + }); + } + } + + componentDidMount() { + const session = LoginStore.snapshot(); + const myPub = session.publicKey; + /* + ProfileLoader.Cache.hook(h, myPub); + SocialNetwork.getProfile( + myPub, + (profile) => { + const irisToActive = + profile && profile.nip05 && profile.nip05valid && profile.nip05.endsWith('@iris.to'); + this.setState({ profile, irisToActive }); + if (profile && !irisToActive) { + this.checkExistingAccount(myPub); + } + }, + true, + ); + + */ + this.checkExistingAccount(myPub); + } + + async checkExistingAccount(pub: any) { + const res = await fetch(`https://api.iris.to/user/find?public_key=${pub}`); + if (res.status === 200) { + const json = await res.json(); + this.setState({ existing: json }); + } + } +} diff --git a/packages/app/src/Element/IrisAccount/ReservedAccount.tsx b/packages/app/src/Element/IrisAccount/ReservedAccount.tsx new file mode 100644 index 00000000..68f23bd0 --- /dev/null +++ b/packages/app/src/Element/IrisAccount/ReservedAccount.tsx @@ -0,0 +1,22 @@ +import AccountName from './AccountName'; + +export default function ReservedAccount({ name = '', enableReserved = () => {}, declineReserved = () => {} }) { + return ( +
+

+ Username iris.to/{name} is reserved for you! +

+ +

+ +

+

+ +

+
+ ); +} diff --git a/packages/app/src/Pages/FreeNostrAddressPage.tsx b/packages/app/src/Pages/FreeNostrAddressPage.tsx new file mode 100644 index 00000000..a3d5ca24 --- /dev/null +++ b/packages/app/src/Pages/FreeNostrAddressPage.tsx @@ -0,0 +1,38 @@ +import FormattedMessage from "@snort/app/src/Element/FormattedMessage"; + +/* +import { IrisHost } from "Const"; +import Nip5Service from "Element/Nip5Service"; + */ + +import messages from "./messages"; +import IrisAccount from "../Element/IrisAccount/IrisAccount"; + +export default function FreeNostrAddressPage() { + return ( +
+

+ +

+

+ +

+

+ +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + +
+ ); +} diff --git a/packages/app/src/Pages/settings/Profile.tsx b/packages/app/src/Pages/settings/Profile.tsx index 8a8a5a70..15f66c90 100644 --- a/packages/app/src/Pages/settings/Profile.tsx +++ b/packages/app/src/Pages/settings/Profile.tsx @@ -162,7 +162,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) { - diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index 316cf278..35b5d009 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -45,6 +45,7 @@ import { db } from "Db"; import { preload, RelayMetrics, UserCache, UserRelays } from "Cache"; import { LoginStore } from "Login"; import { SnortDeckLayout } from "Pages/DeckLayout"; +import FreeNostrAddressPage from "./Pages/FreeNostrAddressPage"; const WasmQueryOptimizer = { expandFilter: (f: ReqFilter) => { @@ -163,6 +164,10 @@ export const router = createBrowserRouter([ element: , children: SettingsRoutes, }, + { + path: "/free-nostr-address", + element: , + }, { path: "/nostr-address", element: ,