diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index ce5eacb..78f4f01 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -25,6 +25,7 @@ import { shorten } from '../../utils' import styles from './style.module.scss' +import { setUserRobotImage } from '../../store/userRobotImage/action' const metadataController = new MetadataController() @@ -39,6 +40,7 @@ export const AppBar = () => { const authState = useSelector((state: State) => state.auth) const metadataState = useSelector((state: State) => state.metadata) + const userRobotImage = useSelector((state: State) => state.userRobotImage) useEffect(() => { if (metadataState) { @@ -47,17 +49,17 @@ export const AppBar = () => { metadataState.content ) - if (picture) { - setUserAvatar(picture) + if (picture || userRobotImage) { + setUserAvatar(picture || userRobotImage) } setUsername(shorten(display_name || name || '', 7)) } else { - setUserAvatar('') + setUserAvatar(userRobotImage || '') setUsername('') } } - }, [metadataState]) + }, [metadataState, userRobotImage]) const handleOpenUserMenu = (event: React.MouseEvent) => { setAnchorElUser(event.currentTarget) @@ -85,8 +87,8 @@ export const AppBar = () => { nsecBunkerPubkey: undefined }) ) - dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent())) + dispatch(setUserRobotImage(null)) // clear authToken saved in local storage clearAuthToken() diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 7e1c00d..c866273 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -34,34 +34,10 @@ export class AuthController { async authenticateAndFindMetadata(pubkey: string) { const emptyMetadata = this.metadataController.getEmptyMetadataEvent() - emptyMetadata.content = JSON.stringify({ - picture: getRoboHashPicture(pubkey) - }) - this.metadataController .findMetadata(pubkey) .then((event) => { if (event) { - // In case of NIP05 there is scenario where login content will be populated but without an image - // In such case we will add robohash image - if (event.content) { - const content = JSON.parse(event.content) - - if (!content) { - event.content = '' - } - - if (!content.picture) { - content.picture = getRoboHashPicture(pubkey) - } - - event.content = JSON.stringify(content) - } else { - event.content = JSON.stringify({ - picture: getRoboHashPicture(pubkey) - }) - } - store.dispatch(setMetadataEvent(event)) } else { store.dispatch(setMetadataEvent(emptyMetadata)) diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index f13440d..360acc6 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -142,6 +142,7 @@ export class MetadataController { public extractProfileMetadataContent = (event: VerifiedEvent) => { try { + if (!event.content) return {} return JSON.parse(event.content) as ProfileMetadata } catch (error) { console.log('error in parsing metadata event content :>> ', error) diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index e1d6f48..9c4f4e4 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,13 +1,14 @@ import { Box } from '@mui/material' import Container from '@mui/material/Container' import { useEffect, useState } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Outlet } from 'react-router-dom' import { AppBar } from '../components/AppBar/AppBar' import { restoreState, setAuthState, setMetadataEvent } from '../store/actions' import { clearAuthToken, clearState, + getRoboHashPicture, loadState, saveNsecBunkerDelegatedKey } from '../utils' @@ -15,12 +16,15 @@ import { LoadingSpinner } from '../components/LoadingSpinner' import { Dispatch } from '../store/store' import { MetadataController, NostrController } from '../controllers' import { LoginMethods } from '../store/auth/types' +import { setUserRobotImage } from '../store/userRobotImage/action' +import { State } from '../store/rootReducer' const metadataController = new MetadataController() export const MainLayout = () => { const dispatch: Dispatch = useDispatch() const [isLoading, setIsLoading] = useState(true) + const authState = useSelector((state: State) => state.auth) useEffect(() => { const logout = () => { @@ -70,6 +74,21 @@ export const MainLayout = () => { setIsLoading(false) }, [dispatch]) + /** + * When authState change user logged in / or app reloaded + * we set robohash avatar in the global state based on user npub + * so that avatar will be consistent across the app when kind 0 is empty + */ + useEffect(() => { + if (authState && authState.loggedIn) { + const pubkey = authState.usersPubkey || authState.keyPair?.public + + if (pubkey) { + dispatch(setUserRobotImage(getRoboHashPicture(pubkey))) + } + } + }, [authState]) + if (isLoading) return return ( diff --git a/src/main.tsx b/src/main.tsx index 0b362fc..d586217 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,7 +14,8 @@ store.subscribe( _.throttle(() => { saveState({ auth: store.getState().auth, - metadata: store.getState().metadata + metadata: store.getState().metadata, + userRobotImage: store.getState().userRobotImage }) }, 1000) ) diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 7dd91d3..edfe5ae 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -43,6 +43,16 @@ export const Login = () => { }, 500) }, []) + /** + * Call login function when enter is pressed + */ + const handleInputKeyDown = (event: any) => { + if (event.code === 'Enter' || event.code === 'NumpadEnter') { + event.preventDefault() + login() + } + } + const loginWithExtension = async () => { setIsLoading(true) setLoadingSpinnerDesc('Capturing pubkey from nostr extension') @@ -303,6 +313,7 @@ export const Login = () => {
Welcome to Sigit setInputValue(e.target.value)} diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index ca6fbd5..318f115 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,6 +1,5 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy' import { - CircularProgress, IconButton, InputProps, List, @@ -12,7 +11,7 @@ import { useTheme } from '@mui/material' import { UnsignedEvent, nip19, kinds, VerifiedEvent } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { MetadataController, NostrController } from '../../controllers' @@ -26,6 +25,7 @@ import { setMetadataEvent } from '../../store/actions' import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoginMethods } from '../../store/auth/types' import { SmartToy } from '@mui/icons-material' +import { getRoboHashPicture } from '../../utils' export const ProfilePage = () => { const theme = useTheme() @@ -42,16 +42,18 @@ export const ProfilePage = () => { useState(null) const [profileMetadata, setProfileMetadata] = useState() const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) - const [avatarLoading, setAvatarLoading] = useState(false) const metadataState = useSelector((state: State) => state.metadata) const keys = useSelector((state: State) => state.auth?.keyPair) const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth) + const userRobotImage = useSelector((state: State) => state.userRobotImage) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') + const robotSet = useRef(1) + useEffect(() => { if (npub) { try { @@ -210,22 +212,21 @@ export const ProfilePage = () => { setSavingProfileMetadata(false) } + /** + * Called by clicking on the robot icon inside Picture URL input + * On every click, next robohash set will be generated. + * There are 5 sets at the moment, after 5th set function will start over from set 1. + */ const generateRobotAvatar = () => { - setAvatarLoading(true) + robotSet.current++ + if (robotSet.current > 5) robotSet.current = 1 - const robotAvatarLink = `https://robohash.org/${npub}.png?set=set3` + const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) setProfileMetadata((prev) => ({ ...prev, - picture: '' + picture: robotAvatarLink })) - - setTimeout(() => { - setProfileMetadata((prev) => ({ - ...prev, - picture: robotAvatarLink - })) - }) } /** @@ -233,21 +234,31 @@ export const ProfilePage = () => { * @returns robohash generate button, loading spinner or no button */ const robohashButton = () => { - if (profileMetadata?.picture?.includes('robohash')) return null - return ( - {avatarLoading ? ( - - ) : ( - - - - )} + + + ) } + /** + * Handles the logic for Image URL. + * If no picture in kind 0 found - use robohash avatar + * + * @returns robohash image url + */ + const getProfileImage = (metadata: ProfileMetadata) => { + if (!isUsersOwnProfile) { + return metadata.picture || getRoboHashPicture(npub!) + } + + // userRobotImage is used only when visiting own profile + // while kind 0 picture is not set + return metadata.picture || userRobotImage || getRoboHashPicture(npub!) + } + return ( <> {isLoading && } @@ -280,11 +291,11 @@ export const ProfilePage = () => { }} > { - setAvatarLoading(false) + onError={(event: any) => { + event.target.src = getRoboHashPicture(npub!) }} className={styles.img} - src={profileMetadata.picture} + src={getProfileImage(profileMetadata)} alt="Profile Image" /> @@ -305,7 +316,7 @@ export const ProfilePage = () => { {editItem('picture', 'Picture URL', undefined, undefined, { - endAdornment: robohashButton() + endAdornment: isUsersOwnProfile ? robohashButton() : undefined })} {editItem('name', 'Username')} diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts index d3047a6..6e0cc66 100644 --- a/src/store/actionTypes.ts +++ b/src/store/actionTypes.ts @@ -7,3 +7,5 @@ export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY' export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS' export const SET_METADATA_EVENT = 'SET_METADATA_EVENT' + +export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE' diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index f56217f..03c9b5c 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -3,13 +3,16 @@ import { combineReducers } from 'redux' import authReducer from './auth/reducer' import { AuthState } from './auth/types' import metadataReducer from './metadata/reducer' +import userRobotImageReducer from './userRobotImage/reducer' export interface State { auth: AuthState metadata?: Event + userRobotImage?: string } export default combineReducers({ auth: authReducer, - metadata: metadataReducer + metadata: metadataReducer, + userRobotImage: userRobotImageReducer }) diff --git a/src/store/userRobotImage/action.ts b/src/store/userRobotImage/action.ts new file mode 100644 index 0000000..5bec4ef --- /dev/null +++ b/src/store/userRobotImage/action.ts @@ -0,0 +1,9 @@ +import * as ActionTypes from '../actionTypes' +import { SetUserRobotImage } from './types' + +export const setUserRobotImage = ( + payload: string | null +): SetUserRobotImage => ({ + type: ActionTypes.SET_USER_ROBOT_IMAGE, + payload +}) diff --git a/src/store/userRobotImage/reducer.ts b/src/store/userRobotImage/reducer.ts new file mode 100644 index 0000000..4235a48 --- /dev/null +++ b/src/store/userRobotImage/reducer.ts @@ -0,0 +1,22 @@ +import * as ActionTypes from '../actionTypes' +import { MetadataDispatchTypes } from './types' + +const initialState: string | null = null + +const reducer = ( + state = initialState, + action: MetadataDispatchTypes +): string | null | undefined => { + switch (action.type) { + case ActionTypes.SET_USER_ROBOT_IMAGE: + return action.payload + + case ActionTypes.RESTORE_STATE: + return action.payload.userRobotImage + + default: + return state + } +} + +export default reducer diff --git a/src/store/userRobotImage/types.ts b/src/store/userRobotImage/types.ts new file mode 100644 index 0000000..05d1475 --- /dev/null +++ b/src/store/userRobotImage/types.ts @@ -0,0 +1,9 @@ +import * as ActionTypes from '../actionTypes' +import { RestoreState } from '../actions' + +export interface SetUserRobotImage { + type: typeof ActionTypes.SET_USER_ROBOT_IMAGE + payload: string | null +} + +export type MetadataDispatchTypes = SetUserRobotImage | RestoreState diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 3a62414..229b7be 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -143,7 +143,11 @@ export const base64DecodeAuthToken = (authToken: string): SignedEvent => { * @param pubkey in hex or npub format * @returns robohash.org url for the avatar */ -export const getRoboHashPicture = (pubkey: string): string => { +export const getRoboHashPicture = ( + pubkey?: string, + set: number = 1 +): string => { + if (!pubkey) return '' const npub = hexToNpub(pubkey) - return `https://robohash.org/${npub}.png?set=set3` + return `https://robohash.org/${npub}.png?set=set${set}` }