From 1a889213fa7be87d32d118dc7ed61ccaf97b8a3d Mon Sep 17 00:00:00 2001 From: daniyal Date: Tue, 24 Sep 2024 21:53:06 +0500 Subject: [PATCH] feat: implement profile edit --- src/components/ProfileSection.tsx | 6 +- src/components/SVGs.tsx | 51 ++ src/controllers/relay.ts | 22 +- src/pages/settings.tsx | 971 ------------------------------ src/pages/settings/admin.tsx | 234 +++++++ src/pages/settings/index.tsx | 164 +++++ src/pages/settings/preference.tsx | 124 ++++ src/pages/settings/profile.tsx | 344 +++++++++++ src/pages/settings/relay.tsx | 242 ++++++++ 9 files changed, 1181 insertions(+), 977 deletions(-) create mode 100644 src/components/SVGs.tsx delete mode 100644 src/pages/settings.tsx create mode 100644 src/pages/settings/admin.tsx create mode 100644 src/pages/settings/index.tsx create mode 100644 src/pages/settings/preference.tsx create mode 100644 src/pages/settings/profile.tsx create mode 100644 src/pages/settings/relay.tsx diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index c2ab74a..58a82c5 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -182,7 +182,7 @@ export const Profile = ({ profile }: ProfileProps) => { - + @@ -231,7 +231,9 @@ type QRButtonWithPopUpProps = { pubkey: string } -const QRButtonWithPopUp = ({ pubkey }: QRButtonWithPopUpProps) => { +export const ProfileQRButtonWithPopUp = ({ + pubkey +}: QRButtonWithPopUpProps) => { const [isOpen, setIsOpen] = useState(false) const nprofile = nip19.nprofileEncode({ diff --git a/src/components/SVGs.tsx b/src/components/SVGs.tsx new file mode 100644 index 0000000..a1e8661 --- /dev/null +++ b/src/components/SVGs.tsx @@ -0,0 +1,51 @@ +export const ProfileSVG = (props: React.SVGProps) => ( + + + +) + +export const RelaySVG = (props: React.SVGProps) => ( + + + +) + +export const PreferenceSVG = (props: React.SVGProps) => ( + + + +) + +export const AdminSVG = (props: React.SVGProps) => ( + + + +) diff --git a/src/controllers/relay.ts b/src/controllers/relay.ts index 0811cd6..bc347bc 100644 --- a/src/controllers/relay.ts +++ b/src/controllers/relay.ts @@ -109,10 +109,24 @@ export class RelayController { ) // Wait for all relay connection attempts to settle (either fulfilled or rejected) - await Promise.allSettled([appRelayPromise, ...relayPromises]) + const results = await Promise.allSettled([ + appRelayPromise, + ...relayPromises + ]) + + // Extract non-null values from fulfilled promises in a single pass + const relays = results.reduce((acc, result) => { + if (result.status === 'fulfilled') { + const value = result.value + if (value) { + acc.push(value) + } + } + return acc + }, []) // If no relays are connected, log an error and return an empty array - if (this.connectedRelays.length === 0) { + if (relays.length === 0) { log(this.debug, LogType.Error, 'No relay is connected!') return [] } @@ -120,7 +134,7 @@ export class RelayController { const publishedOnRelays: string[] = [] // Track relays where the event was successfully published // Create promises to publish the event to each connected relay - const publishPromises = this.connectedRelays.map((relay) => { + const publishPromises = relays.map((relay) => { log( this.debug, LogType.Info, @@ -311,7 +325,7 @@ export class RelayController { const publishedOnRelays: string[] = [] // Track relays where the event was successfully published // Create promises to publish the event to each connected relay - const publishPromises = this.connectedRelays.map((relay) => { + const publishPromises = relays.map((relay) => { log( this.debug, LogType.Info, diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx deleted file mode 100644 index 3783d3f..0000000 --- a/src/pages/settings.tsx +++ /dev/null @@ -1,971 +0,0 @@ -import { logout } from 'nostr-login' -import { Link, useLocation } from 'react-router-dom' -import { toast } from 'react-toastify' -import { InputField } from '../components/Inputs' -import { ProfileSection } from '../components/ProfileSection' -import { useAppSelector } from '../hooks' -import { appRoutes } from '../routes' -import { AuthMethod } from '../store/reducers/user' -import '../styles/feed.css' -import '../styles/innerPage.css' -import '../styles/popup.css' -import '../styles/profile.css' -import '../styles/settings.css' -import '../styles/styles.css' -import '../styles/write.css' -import { copyTextToClipboard } from '../utils' -import { MetadataController } from '../controllers' -import { useEffect, useState } from 'react' - -export const SettingsPage = () => { - const location = useLocation() - const userState = useAppSelector((state) => state.user) - - return ( -
-
-
-
- - {location.pathname === appRoutes.settingsProfile && ( - - )} - {location.pathname === appRoutes.settingsRelays && ( - - )} - {location.pathname === appRoutes.settingsPreferences && ( - - )} - {location.pathname === appRoutes.settingsAdmin && } - {userState.auth && userState.user?.pubkey && ( - - )} -
-
-
-
- ) -} - -const SettingTabs = () => { - const location = useLocation() - const [isAdmin, setIsAdmin] = useState(false) - const userState = useAppSelector((state) => state.user) - - useEffect(() => { - MetadataController.getInstance().then((controller) => { - if (userState.auth && userState.user?.npub) { - setIsAdmin( - controller.adminNpubs.includes(userState.user.npub as string) - ) - } else { - setIsAdmin(false) - } - }) - }, [userState]) - - const handleSignOut = () => { - logout() - } - - return ( -
-
-
-

Settings (WIP)

-
-
- - - - - Profile - - - - - - Relays - - - - - - Preference - - {isAdmin && ( - - - - - Admin - - )} -
- - {userState.auth && - userState.auth.method === AuthMethod.Local && - userState.auth.localNsec && ( -
- -

- NOTICE: Make sure you save your private key (nsec) somewhere - safe. -

-
- - -
-

- WARNING: Do not sign-out without saving your nsec somewhere - safe. Otherwise, you'll lose access to your "account". -

-
- )} - - {userState.auth && ( - - )} -
-
- ) -} - -const ProfileSettings = () => { - return ( -
-
-
-
-
-
- -
-
-
-
-
-
-
-
-

User name

-

- nip5handle@domain.com -

-
-
-
-
-
-
-

- npub1address -

-
-
-
- - - -
-
- - - -
-
- - - -
-
-
-
-

- user bio, this is a long string of temporary text that would - be replaced with the user bio from their metada address -

-
-
-
-
-
- {}} - /> - {}} - /> - {}} - /> - {}} - /> - {}} - /> -
-
- -
-
-
-
- ) -} - -const RelaySettings = () => { - return ( -
-
-
-
- -
- {usersRelays.map((relay, index) => ( - - ))} -
-
- -
- {}} - /> - - -
- -
-
- -

- We recommend adding one of our relays if you're planning to - frequently use DEG Mods, for a better experience. -

-
-
- {degmodsRelays.map((relay, index) => ( - - ))} -
-
- -
-
- -

- Relays we recommend using as they support the same functionalities - that our relays provide. -

-
-
- {recommendRelays.map((relay, index) => ( - - ))} -
-
-
- ) -} - -const RelayListItem = ({ - item, - isOwnRelay -}: { - item: RelayItem - isOwnRelay?: boolean -}) => { - return ( -
-
-
-
-
-

{item.url}

-
- - - - - - - {item.subscribedTitle && ( - - - - )} -
-
-
- {isOwnRelay && ( -
- - -
- )} - {!isOwnRelay && ( - - )} -
-
- ) -} - -// todo: use components from Input.tsx -const PreferencesSetting = () => { - return ( -
-
-
-
-
-

Notifications

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-

Not Safe For Work (NSFW)

-
-
- - -
-
-
-
-

Web of Trust (WoT) level

-
-

- This affects what posts you see, reactions, DMs, and - notifications. Learn more: Link -

-
- -

10

-
-
- - -
-
-
- -
-
-
-
- ) -} - -// todo: use components from Input.tsx -const AdminSetting = () => { - return ( -
-
-
-
-
-

Slider Featured Mods

- -
-
- - -
-
- - -
-
-
-
-

Featured Games

- -
-
- - -
-
- - -
-
-
-
-

Featured Mods

- -
-
- - -
-
- - -
-
-
-
-

Blog writers

- -
-
- - -
-
- - -
-
-
- -
-
-
-
- ) -} - -interface RelayItem { - url: string - backgroundColor: string - readTitle: string - writeTitle: string - subscribedTitle: string -} - -const usersRelays: RelayItem[] = [ - { - url: 'wss://relay.wibblywobbly.com', - backgroundColor: '#cd4d45', - readTitle: 'Read', - writeTitle: 'Write', - subscribedTitle: '' - }, - { - url: 'wss://relay.wibblywobbly.com', - backgroundColor: '#60ae60', - readTitle: 'Read', - writeTitle: 'Write', - subscribedTitle: 'Paid (Subscribed)' - }, - { - url: 'wss://relay.degmods.com', - backgroundColor: '#60ae60', - readTitle: 'Read', - writeTitle: 'Write', - subscribedTitle: '' - } -] - -const degmodsRelays: RelayItem[] = [ - { - url: 'wss://relay1.degmods.com', - backgroundColor: '#60ae60', - readTitle: 'Read', - writeTitle: 'Write', - subscribedTitle: '' - }, - { - url: 'wss://relay2.degmods.com', - backgroundColor: '#60ae60', - readTitle: 'Read', - writeTitle: 'Write', - subscribedTitle: '' - } -] - -const recommendRelays: RelayItem[] = [ - { - url: 'wss://relay1.degmods.com', - backgroundColor: '#60ae60', - readTitle: 'Read', - writeTitle: 'Write', - subscribedTitle: '' - }, - { - url: 'wss://relay2.degmods.com', - backgroundColor: '#60ae60', - readTitle: 'Read', - writeTitle: 'Write', - subscribedTitle: '' - } -] diff --git a/src/pages/settings/admin.tsx b/src/pages/settings/admin.tsx new file mode 100644 index 0000000..70253a2 --- /dev/null +++ b/src/pages/settings/admin.tsx @@ -0,0 +1,234 @@ +// todo: use components from Input.tsx +export const AdminSetting = () => { + return ( +
+
+
+
+
+

Slider Featured Mods

+ +
+
+ + +
+
+ + +
+
+
+
+

Featured Games

+ +
+
+ + +
+
+ + +
+
+
+
+

Featured Mods

+ +
+
+ + +
+
+ + +
+
+
+
+

Blog writers

+ +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ ) +} diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx new file mode 100644 index 0000000..1fe2351 --- /dev/null +++ b/src/pages/settings/index.tsx @@ -0,0 +1,164 @@ +import { AdminSVG, PreferenceSVG, ProfileSVG, RelaySVG } from 'components/SVGs' +import { MetadataController } from 'controllers' +import { useAppSelector } from 'hooks' +import { logout } from 'nostr-login' +import { useEffect, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { toast } from 'react-toastify' +import { appRoutes } from 'routes' +import { AuthMethod } from 'store/reducers/user' +import { copyTextToClipboard } from 'utils' +import { ProfileSettings } from './profile' +import { RelaySettings } from './relay' +import { PreferencesSetting } from './preference' +import { AdminSetting } from './admin' +import { ProfileSection } from 'components/ProfileSection' + +export const SettingsPage = () => { + const location = useLocation() + const userState = useAppSelector((state) => state.user) + + return ( +
+
+
+
+ + {location.pathname === appRoutes.settingsProfile && ( + + )} + {location.pathname === appRoutes.settingsRelays && ( + + )} + {location.pathname === appRoutes.settingsPreferences && ( + + )} + {location.pathname === appRoutes.settingsAdmin && } + {userState.auth && userState.user?.pubkey && ( + + )} +
+
+
+
+ ) +} + +const SettingTabs = () => { + const location = useLocation() + const [isAdmin, setIsAdmin] = useState(false) + const userState = useAppSelector((state) => state.user) + + useEffect(() => { + MetadataController.getInstance().then((controller) => { + if (userState.auth && userState.user?.npub) { + setIsAdmin( + controller.adminNpubs.includes(userState.user.npub as string) + ) + } else { + setIsAdmin(false) + } + }) + }, [userState]) + + const handleSignOut = () => { + logout() + } + + const navLinks = [ + { path: appRoutes.settingsProfile, label: 'Profile', icon: }, + { path: appRoutes.settingsRelays, label: 'Relays', icon: }, + { + path: appRoutes.settingsPreferences, + label: 'Preferences', + icon: + } + ] + + if (isAdmin) { + navLinks.push({ + path: appRoutes.settingsAdmin, + label: 'Admin', + icon: + }) + } + + const renderNavLink = (path: string, label: string, icon: JSX.Element) => ( + + {icon} + {label} + + ) + + return ( +
+
+
+

Settings (WIP)

+
+
+ {navLinks.map(({ path, label, icon }) => + renderNavLink(path, label, icon) + )} +
+ + {userState.auth && + userState.auth.method === AuthMethod.Local && + userState.auth.localNsec && ( +
+ +

+ NOTICE: Make sure you save your private key (nsec) somewhere + safe. +

+
+ + +
+

+ WARNING: Do not sign-out without saving your nsec somewhere + safe. Otherwise, you'll lose access to your "account". +

+
+ )} + + {userState.auth && ( + + )} +
+
+ ) +} diff --git a/src/pages/settings/preference.tsx b/src/pages/settings/preference.tsx new file mode 100644 index 0000000..170cabf --- /dev/null +++ b/src/pages/settings/preference.tsx @@ -0,0 +1,124 @@ +// todo: use components from Input.tsx +export const PreferencesSetting = () => { + return ( +
+
+
+
+
+

Notifications

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

Not Safe For Work (NSFW)

+
+
+ + +
+
+
+
+

Web of Trust (WoT) level

+
+

+ This affects what posts you see, reactions, DMs, and + notifications. Learn more: Link +

+
+ +

10

+
+
+ + +
+
+
+ +
+
+
+
+ ) +} diff --git a/src/pages/settings/profile.tsx b/src/pages/settings/profile.tsx new file mode 100644 index 0000000..dbf6d65 --- /dev/null +++ b/src/pages/settings/profile.tsx @@ -0,0 +1,344 @@ +import { InputField } from 'components/Inputs' +import { ProfileQRButtonWithPopUp } from 'components/ProfileSection' +import { useAppDispatch, useAppSelector } from 'hooks' +import { kinds, nip19, UnsignedEvent, Event } from 'nostr-tools' +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { toast } from 'react-toastify' +import { appRoutes, getProfilePageRoute } from 'routes' +import { copyTextToClipboard, log, LogType, now, npubToHex } from 'utils' +import 'styles/profile.css' +import { + NDKEvent, + NDKUserProfile, + profileFromEvent, + serializeProfile +} from '@nostr-dev-kit/ndk' +import { RelayController } from 'controllers' +import { LoadingSpinner } from 'components/LoadingSpinner' +import { setUser } from 'store/reducers/user' + +type FormState = { + name: string + displayName: string + bio: string + picture: string + banner: string + nip05: string + lud16: string +} + +const defaultFormState: FormState = { + name: '', + displayName: '', + bio: '', + picture: '', + banner: '', + nip05: '', + lud16: '' +} + +export const ProfileSettings = () => { + const dispatch = useAppDispatch() + const userState = useAppSelector((state) => state.user) + + const [isPublishing, setIsPublishing] = useState(false) + const [formState, setFormState] = useState(defaultFormState) + + useEffect(() => { + if (userState.auth && userState.user) { + const { + name, + displayName, + about, + bio, + image, + picture, + banner, + nip05, + lud16 + } = userState.user + + setFormState({ + name: name || '', + displayName: displayName || '', + bio: bio || about || '', + picture: typeof picture === 'string' ? picture : image || '', + banner: banner || '', + nip05: nip05 || '', + lud16: lud16 || '' + }) + } else { + setFormState(defaultFormState) + } + }, [userState]) + + const handleInputChange = (field: string, value: string) => { + setFormState((prev) => ({ + ...prev, + [field]: value + })) + } + + const banner = + formState.banner || '/assets/img/DEGMods%20Placeholder%20Img.png' + + const picture = + formState.picture || '/assets/img/DEG%20Mods%20Default%20PP.png' + + const name = formState.displayName || formState.name || 'User name' + + const nip05 = formState.nip05 || 'nip5handle@domain.com' + + const npub = (userState.user?.npub as string) || 'npub1address' + + const bio = + formState.bio || + 'user bio, this is a long string of temporary text that would be replaced with the user bio from their metadata address' + + // In case user is not logged in clicking on profile link will navigate to homepage + let profileRoute = appRoutes.home + if (userState.auth && userState.user) { + const hexPubkey = npubToHex(userState.user.npub as string) + + if (hexPubkey) { + profileRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: hexPubkey + }) + ) + } + } + + const handleCopy = async () => { + copyTextToClipboard(npub).then((isCopied) => { + if (isCopied) { + toast.success('Npub copied to clipboard!') + } else { + toast.error( + 'Failed to copy, look into console for more details on error!' + ) + } + }) + } + + const handlePublish = async () => { + if (!userState.auth && !userState.user?.pubkey) return + + setIsPublishing(true) + + const prevProfile = userState.user as NDKUserProfile + const updatedProfile = { + ...prevProfile, + name: formState.name, + displayName: formState.displayName, + bio: formState.bio, + picture: formState.picture, + banner: formState.banner, + nip05: formState.nip05, + lud16: formState.lud16 + } + + const serializedProfile = serializeProfile(updatedProfile) + + const unsignedEvent: UnsignedEvent = { + kind: kinds.Metadata, + tags: [], + content: serializedProfile, + created_at: now(), + pubkey: userState.user?.pubkey as string + } + + const signedEvent = await window.nostr + ?.signEvent(unsignedEvent) + .then((event) => event as Event) + .catch((err) => { + toast.error('Failed to sign the event!') + log(true, LogType.Error, 'Failed to sign the event!', err) + return null + }) + + if (!signedEvent) { + setIsPublishing(false) + return + } + + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event + ) + + // Handle cases where publishing failed or succeeded + if (publishedOnRelays.length === 0) { + toast.error('Failed to publish event on any relay') + } else { + toast.success( + `Event published successfully to the following relays\n\n${publishedOnRelays.join( + '\n' + )}` + ) + + const ndkEvent = new NDKEvent(undefined, signedEvent) + const userProfile = profileFromEvent(ndkEvent) + dispatch(setUser(userProfile)) + } + + setIsPublishing(false) + } + + return ( + <> + {isPublishing && } +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+

{name}

+

{nip05}

+
+
+
+ +
+
+

+ {npub} +

+
+
+
+ + + +
+ {typeof userState.user?.pubkey === 'string' && ( + + )} +
+
+
+

{bio}

+
+
+
+
+
+ + + + + + + +
+
+ +
+
+
+
+ + ) +} diff --git a/src/pages/settings/relay.tsx b/src/pages/settings/relay.tsx new file mode 100644 index 0000000..508b21c --- /dev/null +++ b/src/pages/settings/relay.tsx @@ -0,0 +1,242 @@ +import { InputField } from 'components/Inputs' + +export const RelaySettings = () => { + return ( +
+
+
+
+ +
+ {usersRelays.map((relay, index) => ( + + ))} +
+
+ +
+ {}} + /> + + +
+ +
+
+ +

+ We recommend adding one of our relays if you're planning to + frequently use DEG Mods, for a better experience. +

+
+
+ {degmodsRelays.map((relay, index) => ( + + ))} +
+
+ +
+
+ +

+ Relays we recommend using as they support the same functionalities + that our relays provide. +

+
+
+ {recommendRelays.map((relay, index) => ( + + ))} +
+
+
+ ) +} + +const RelayListItem = ({ + item, + isOwnRelay +}: { + item: RelayItem + isOwnRelay?: boolean +}) => { + return ( +
+
+
+
+
+

{item.url}

+
+ + + + + + + {item.subscribedTitle && ( + + + + )} +
+
+
+ {isOwnRelay && ( +
+ + +
+ )} + {!isOwnRelay && ( + + )} +
+
+ ) +} + +interface RelayItem { + url: string + backgroundColor: string + readTitle: string + writeTitle: string + subscribedTitle: string +} + +const usersRelays: RelayItem[] = [ + { + url: 'wss://relay.wibblywobbly.com', + backgroundColor: '#cd4d45', + readTitle: 'Read', + writeTitle: 'Write', + subscribedTitle: '' + }, + { + url: 'wss://relay.wibblywobbly.com', + backgroundColor: '#60ae60', + readTitle: 'Read', + writeTitle: 'Write', + subscribedTitle: 'Paid (Subscribed)' + }, + { + url: 'wss://relay.degmods.com', + backgroundColor: '#60ae60', + readTitle: 'Read', + writeTitle: 'Write', + subscribedTitle: '' + } +] + +const degmodsRelays: RelayItem[] = [ + { + url: 'wss://relay1.degmods.com', + backgroundColor: '#60ae60', + readTitle: 'Read', + writeTitle: 'Write', + subscribedTitle: '' + }, + { + url: 'wss://relay2.degmods.com', + backgroundColor: '#60ae60', + readTitle: 'Read', + writeTitle: 'Write', + subscribedTitle: '' + } +] + +const recommendRelays: RelayItem[] = [ + { + url: 'wss://relay1.degmods.com', + backgroundColor: '#60ae60', + readTitle: 'Read', + writeTitle: 'Write', + subscribedTitle: '' + }, + { + url: 'wss://relay2.degmods.com', + backgroundColor: '#60ae60', + readTitle: 'Read', + writeTitle: 'Write', + subscribedTitle: '' + } +]