diff --git a/packages/app/_headers b/packages/app/_headers
index 5921f994..46544d63 100644
--- a/packages/app/_headers
+++ b/packages/app/_headers
@@ -1,2 +1,2 @@
/*
- Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://analytics.v0l.io https://platform.twitter.com https://embed.tidal.com;
\ No newline at end of file
+ Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://analytics.v0l.io https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
\ No newline at end of file
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..573e766d
--- /dev/null
+++ b/packages/app/src/Element/IrisAccount/AccountName.tsx
@@ -0,0 +1,31 @@
+import { useNavigate } from "react-router-dom";
+import FormattedMessage from "Element/FormattedMessage";
+
+export default function AccountName({ name = "", link = true }) {
+ const navigate = useNavigate();
+ return (
+ <>
+
+ : {name}
+
+
+
+ : {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..fb77be99
--- /dev/null
+++ b/packages/app/src/Element/IrisAccount/ActiveAccount.tsx
@@ -0,0 +1,74 @@
+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";
+import FormattedMessage from "Element/FormattedMessage";
+
+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 (
+
+ );
+}
diff --git a/packages/app/src/Element/IrisAccount/IrisAccount.tsx b/packages/app/src/Element/IrisAccount/IrisAccount.tsx
new file mode 100644
index 00000000..9923d630
--- /dev/null
+++ b/packages/app/src/Element/IrisAccount/IrisAccount.tsx
@@ -0,0 +1,307 @@
+/* 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";
+import FormattedMessage from "Element/FormattedMessage";
+import { injectIntl } from "react-intl";
+import messages from "Element/messages";
+
+declare global {
+ interface Window {
+ cf_turnstile_callback: any;
+ }
+}
+
+type Props = {
+ intl: any;
+};
+
+// TODO split into smaller components
+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 = (
+
+
+ (iris.to/username)
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {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: this.props.intl.formatMessage(messages.IrisUserNameLengthError),
+ });
+ return;
+ }
+ if (!newUserName.match(/^[a-z0-9_.]+$/)) {
+ this.setState({
+ newUserName,
+ newUserNameValid: false,
+ invalidUsernameMessage: this.props.intl.formatMessage(messages.IrisUserNameFormatError),
+ });
+ 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}`);
+ 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(() => {
+ const profile = ProfileLoader.Cache.getFromCache(myPub);
+ const irisToActive = profile && profile.nip05 && profile.nip05.endsWith("@iris.to");
+ this.setState({ profile, irisToActive });
+ if (profile && !irisToActive) {
+ this.checkExistingAccount(myPub);
+ }
+ }, myPub);
+ 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 });
+ }
+ }
+}
+
+export default injectIntl(IrisAccount);
diff --git a/packages/app/src/Element/IrisAccount/ReservedAccount.tsx b/packages/app/src/Element/IrisAccount/ReservedAccount.tsx
new file mode 100644
index 00000000..3c4818ac
--- /dev/null
+++ b/packages/app/src/Element/IrisAccount/ReservedAccount.tsx
@@ -0,0 +1,26 @@
+import AccountName from "./AccountName";
+import FormattedMessage from "Element/FormattedMessage";
+
+export default function ReservedAccount({ name = "", enableReserved = () => {}, declineReserved = () => {} }) {
+ return (
+
+
+ {s} }}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/app/src/Element/messages.ts b/packages/app/src/Element/messages.ts
index 904ac049..9d2f31f4 100644
--- a/packages/app/src/Element/messages.ts
+++ b/packages/app/src/Element/messages.ts
@@ -98,4 +98,6 @@ export default defineMessages({
ConfirmUnpin: { defaultMessage: "Are you sure you want to unpin this note?" },
ReactionsLink: { defaultMessage: "{n} Reactions" },
ReBroadcast: { defaultMessage: "Broadcast Again" },
+ IrisUserNameLengthError: { defaultMessage: "Name must be between 1 and 32 characters" },
+ IrisUserNameFormatError: { defaultMessage: "Username must only contain lowercase letters and numbers" },
});
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) {
-