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}
+
+
+
+ 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)
+
+
+ );
+ }
+
+ 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) {
-