diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a938fea..42d3327 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,15 +1,8 @@ module.exports = { extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], parser: "@typescript-eslint/parser", - plugins: ["@typescript-eslint", "formatjs"], - rules: { - "formatjs/enforce-id": [ - "error", - { - idInterpolationPattern: "[sha512:contenthash:base64:6]", - }, - ], - }, + plugins: ["@typescript-eslint"], + rules: {}, root: true, ignorePatterns: ["build/", "*.test.ts", "*.js"], env: { diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 0000000..52ea004 --- /dev/null +++ b/babel.config.json @@ -0,0 +1,11 @@ +{ + "plugins": [ + [ + "formatjs", + { + "idInterpolationPattern": "[sha512:contenthash:base64:6]", + "ast": true + } + ] + ] +} diff --git a/package.json b/package.json index b93bdcc..50144a1 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20231218.0", "@formatjs/cli": "^6.1.3", - "@formatjs/ts-transformer": "^3.13.3", "@testing-library/dom": "^9.3.1", "@types/node": "^20.10.3", "@types/react": "^18.2.21", @@ -84,8 +83,8 @@ "@vitejs/plugin-react": "^4.2.0", "@webbtc/webln-types": "^1.0.12", "autoprefixer": "^10.4.16", + "babel-plugin-formatjs": "^10.5.13", "eslint": "^8.48.0", - "eslint-plugin-formatjs": "^4.11.3", "postcss": "^8.4.32", "prettier": "^2.8.8", "prop-types": "^15.8.1", diff --git a/public/icons.svg b/public/icons.svg index 43aa514..3cf4131 100644 --- a/public/icons.svg +++ b/public/icons.svg @@ -125,5 +125,22 @@ + + + + + + + + + + + + + + + + + diff --git a/src/element/category-link.tsx b/src/element/category-link.tsx new file mode 100644 index 0000000..250b62a --- /dev/null +++ b/src/element/category-link.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { Icon } from "./icon"; + +export default function CategoryLink({ id, name, icon }: { id: string; name: ReactNode; icon: string }) { + return ( + + {name} + + + ); +} diff --git a/src/element/external-link.tsx b/src/element/external-link.tsx index f6704a9..0fa1f65 100644 --- a/src/element/external-link.tsx +++ b/src/element/external-link.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { Icon } from "./icon"; +import { Link } from "react-router-dom"; interface ExternalLinkProps { href: string; @@ -8,9 +9,9 @@ interface ExternalLinkProps { export function ExternalLink({ children, href }: ExternalLinkProps) { return ( - + {children} - + ); } diff --git a/src/element/hypertext.tsx b/src/element/hypertext.tsx index a6662e8..69a42ef 100644 --- a/src/element/hypertext.tsx +++ b/src/element/hypertext.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { NostrLink } from "./nostr-link"; import { MediaURL } from "./collapsible"; +import { ExternalLink } from "./external-link"; const FileExtensionRegex = /\.([\w]+)$/i; @@ -50,22 +51,16 @@ export function HyperText({ link, children }: HyperTextProps) { ); } default: - return {children || url.toString()}; + return {children || url.toString()}; } } else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") { return ; } else { - - {children} - ; + {children}; } } catch (error) { console.error(error); // Ignore the error. } - return ( - - {children} - - ); + return {children}; } diff --git a/src/element/login-signup.tsx b/src/element/login-signup.tsx index 839d9b4..ad62289 100644 --- a/src/element/login-signup.tsx +++ b/src/element/login-signup.tsx @@ -25,6 +25,7 @@ import { openFile } from "@/utils"; import { DefaultProvider, StreamProviderInfo } from "@/providers"; import { NostrStreamProvider } from "@/providers/zsz"; import { DefaultButton, Layer1Button } from "./buttons"; +import { ExternalLink } from "./external-link"; enum Stage { Login = 0, @@ -205,9 +206,9 @@ export function LoginSignup({ close }: { close: () => void }) { id="Z8ZOEY" values={{ nostrlink: ( - + - + ), }} /> diff --git a/src/element/nostr-link.tsx b/src/element/nostr-link.tsx index f06fecf..115bcb7 100644 --- a/src/element/nostr-link.tsx +++ b/src/element/nostr-link.tsx @@ -1,13 +1,12 @@ import { NostrPrefix, tryParseNostrLink } from "@snort/system"; import { Mention } from "./mention"; +import { ExternalLink } from "./external-link"; export function NostrLink({ link }: { link: string }) { const nav = tryParseNostrLink(link); if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) { return ; } else { - - {link} - ; + {link}; } } diff --git a/src/element/profile-editor.tsx b/src/element/profile-editor.tsx new file mode 100644 index 0000000..74acf71 --- /dev/null +++ b/src/element/profile-editor.tsx @@ -0,0 +1,297 @@ +import { useState, useEffect, useContext } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { LNURL, fetchNip05Pubkey } from "@snort/shared"; +import { mapEventToProfile } from "@snort/system"; +import { SnortContext, useUserProfile } from "@snort/system-react"; +import { useLogin } from "@/hooks/login"; +import { debounce, openFile } from "@/utils"; +import { PrimaryButton } from "./buttons"; +import { VoidApi } from "@void-cat/api"; + +const MaxUsernameLength = 100; +const MaxAboutLength = 500; + +export function ProfileEditor({ onClose }: { onClose: () => void }) { + const login = useLogin(); + const user = useUserProfile(login?.pubkey); + const { formatMessage } = useIntl(); + const system = useContext(SnortContext); + + const [error, setError] = useState(); + const [name, setName] = useState(); + const [picture, setPicture] = useState(); + const [banner, setBanner] = useState(); + const [about, setAbout] = useState(); + const [website, setWebsite] = useState(); + const [nip05, setNip05] = useState(); + const [lud16, setLud16] = useState(); + const [nip05AddressValid, setNip05AddressValid] = useState(); + const [invalidNip05AddressMessage, setInvalidNip05AddressMessage] = useState(); + const [usernameValid, setUsernameValid] = useState(); + const [invalidUsernameMessage, setInvalidUsernameMessage] = useState(); + const [aboutValid, setAboutValid] = useState(); + const [invalidAboutMessage, setInvalidAboutMessage] = useState(); + const [lud16Valid, setLud16Valid] = useState(); + const [invalidLud16Message, setInvalidLud16Message] = useState(); + + useEffect(() => { + if (user) { + setName(user.name); + setPicture(user.picture); + setBanner(user.banner); + setAbout(user.about); + setWebsite(user.website); + setNip05(user.nip05); + setLud16(user.lud16); + } + }, [user]); + + useEffect(() => { + return debounce(500, async () => { + if (lud16) { + try { + await new LNURL(lud16).load(); + setLud16Valid(true); + setInvalidLud16Message(""); + } catch (e) { + setLud16Valid(false); + setInvalidLud16Message( + formatMessage({ + defaultMessage: "Invalid lightning address", + }) + ); + } + } else { + setInvalidLud16Message(""); + } + }); + }, [formatMessage, lud16]); + + useEffect(() => { + async function nip05NostrAddressVerification(nip05Domain: string | undefined, nip05Name: string | undefined) { + try { + const result = await fetchNip05Pubkey(nip05Name!, nip05Domain!); + if (result) { + if (result === login?.pubkey) { + setNip05AddressValid(true); + } else { + setInvalidNip05AddressMessage( + formatMessage({ defaultMessage: "Nostr address does not belong to you", id: "01iNut" }) + ); + } + } else { + setNip05AddressValid(false); + setInvalidNip05AddressMessage( + formatMessage({ + defaultMessage: "Invalid nostr address", + }) + ); + } + } catch (e) { + setNip05AddressValid(false); + setInvalidNip05AddressMessage( + formatMessage({ + defaultMessage: "Invalid nostr address", + }) + ); + } + } + return debounce(500, async () => { + const Nip05AddressElements = nip05?.split("@") ?? []; + if ((nip05?.length ?? 0) === 0) { + setNip05AddressValid(false); + setInvalidNip05AddressMessage(""); + } else if (Nip05AddressElements.length < 2) { + setNip05AddressValid(false); + setInvalidNip05AddressMessage( + formatMessage({ + defaultMessage: "Invalid nostr address", + }) + ); + } else if (Nip05AddressElements.length === 2) { + nip05NostrAddressVerification(Nip05AddressElements.pop(), Nip05AddressElements.pop()); + } else { + setNip05AddressValid(false); + } + }); + }, [formatMessage, login?.pubkey, nip05]); + + async function uploadAvatar() { + const defaultError = formatMessage({ + defaultMessage: "Avatar upload fialed", + id: "uTonxS", + }); + + setError(undefined); + try { + const file = await openFile(); + if (file) { + const VoidCatHost = "https://void.cat"; + const api = new VoidApi(VoidCatHost); + const uploader = api.getUploader(file); + const result = await uploader.upload({ + "V-Strip-Metadata": "true", + }); + console.debug(result); + if (result.ok) { + const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`; + setPicture(resultUrl); + } else { + setError(new Error(result.errorMessage ?? defaultError)); + } + } + } catch { + setError(new Error(defaultError)); + } + } + + async function saveProfile() { + // copy user object and delete internal fields + const userCopy = { + ...user, + name, + about, + picture, + banner, + website, + nip05, + lud16, + } 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); + + const publisher = login?.publisher(); + if (publisher) { + const ev = await publisher.metadata(userCopy); + system.BroadcastEvent(ev); + + const newProfile = mapEventToProfile(ev); + if (newProfile) { + await system.profileLoader.cache.update(newProfile); + } + onClose(); + } + } + + async function onNip05Change(e: React.ChangeEvent) { + const Nip05Address = e.target.value.toLowerCase(); + setNip05(Nip05Address); + } + + async function onLimitCheck(val: string, field: string) { + if (field === "username") { + setName(val); + if (val?.length >= MaxUsernameLength) { + setUsernameValid(false); + setInvalidUsernameMessage( + formatMessage({ + defaultMessage: "Username is too long", + }) + ); + } else { + setUsernameValid(true); + setInvalidUsernameMessage(""); + } + } else if (field === "about") { + setAbout(val); + if (val?.length >= MaxAboutLength) { + setAboutValid(false); + setInvalidAboutMessage( + formatMessage({ + defaultMessage: "About too long", + }) + ); + } else { + setAboutValid(true); + setInvalidAboutMessage(""); + } + } + } + + async function onLud16Change(address: string) { + setLud16(address); + } + + function editor() { + if (!login?.pubkey) return; + + return ( +
+
+ +
uploadAvatar()}> + +
+
+
+

+ +

+ onLimitCheck(e.target.value, "username")} + maxLength={MaxUsernameLength} + /> +
{usernameValid === false ? {invalidUsernameMessage} : <>}
+
+
+

+ +

+ +
{aboutValid === false ? {invalidAboutMessage} : <>}
+
+
+

+ +

+ setWebsite(e.target.value)} /> +
+
+

+ +

+
+ onNip05Change(e)} /> +
{!nip05AddressValid && {invalidNip05AddressMessage}}
+ + + +
+
+
+

+ +

+ onLud16Change(e.target.value.toLowerCase())} + /> +
{lud16Valid === false ? {invalidLud16Message} : <>}
+
+ {error && {error.message}} + saveProfile()}> + + +
+ ); + } + + return editor(); +} diff --git a/src/element/share-menu.tsx b/src/element/share-menu.tsx index 5c3f586..df106d6 100644 --- a/src/element/share-menu.tsx +++ b/src/element/share-menu.tsx @@ -59,7 +59,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) { menuClassName="ctx-menu" menuButton={ - + }> - + + + { + window.open( + `https://twitter.com/intent/tweet?text=${encodeURIComponent(message)}&via=zap_stream`, + "_blank" + ); + }}> + + {share && ( setShare(undefined)}>

- +