Merge pull request 'Profile pictures incosistencies' (#58) from pictures-fixes into main
Some checks failed
Release / build_and_release (push) Failing after 32s

Reviewed-on: https://git.sigit.io/sig/it/pulls/58
Reviewed-by: Y <yury@4gl.io>
This commit is contained in:
b 2024-05-17 11:52:35 +00:00
commit f7723a3ee9
13 changed files with 130 additions and 60 deletions

View File

@ -25,6 +25,7 @@ import {
shorten shorten
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { setUserRobotImage } from '../../store/userRobotImage/action'
const metadataController = new MetadataController() const metadataController = new MetadataController()
@ -39,6 +40,7 @@ export const AppBar = () => {
const authState = useSelector((state: State) => state.auth) const authState = useSelector((state: State) => state.auth)
const metadataState = useSelector((state: State) => state.metadata) const metadataState = useSelector((state: State) => state.metadata)
const userRobotImage = useSelector((state: State) => state.userRobotImage)
useEffect(() => { useEffect(() => {
if (metadataState) { if (metadataState) {
@ -47,17 +49,17 @@ export const AppBar = () => {
metadataState.content metadataState.content
) )
if (picture) { if (picture || userRobotImage) {
setUserAvatar(picture) setUserAvatar(picture || userRobotImage)
} }
setUsername(shorten(display_name || name || '', 7)) setUsername(shorten(display_name || name || '', 7))
} else { } else {
setUserAvatar('') setUserAvatar(userRobotImage || '')
setUsername('') setUsername('')
} }
} }
}, [metadataState]) }, [metadataState, userRobotImage])
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => { const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget) setAnchorElUser(event.currentTarget)
@ -85,8 +87,8 @@ export const AppBar = () => {
nsecBunkerPubkey: undefined nsecBunkerPubkey: undefined
}) })
) )
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent())) dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
dispatch(setUserRobotImage(null))
// clear authToken saved in local storage // clear authToken saved in local storage
clearAuthToken() clearAuthToken()

View File

@ -34,34 +34,10 @@ export class AuthController {
async authenticateAndFindMetadata(pubkey: string) { async authenticateAndFindMetadata(pubkey: string) {
const emptyMetadata = this.metadataController.getEmptyMetadataEvent() const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
emptyMetadata.content = JSON.stringify({
picture: getRoboHashPicture(pubkey)
})
this.metadataController this.metadataController
.findMetadata(pubkey) .findMetadata(pubkey)
.then((event) => { .then((event) => {
if (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)) store.dispatch(setMetadataEvent(event))
} else { } else {
store.dispatch(setMetadataEvent(emptyMetadata)) store.dispatch(setMetadataEvent(emptyMetadata))

View File

@ -142,6 +142,7 @@ export class MetadataController {
public extractProfileMetadataContent = (event: VerifiedEvent) => { public extractProfileMetadataContent = (event: VerifiedEvent) => {
try { try {
if (!event.content) return {}
return JSON.parse(event.content) as ProfileMetadata return JSON.parse(event.content) as ProfileMetadata
} catch (error) { } catch (error) {
console.log('error in parsing metadata event content :>> ', error) console.log('error in parsing metadata event content :>> ', error)

View File

@ -1,13 +1,14 @@
import { Box } from '@mui/material' import { Box } from '@mui/material'
import Container from '@mui/material/Container' import Container from '@mui/material/Container'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { AppBar } from '../components/AppBar/AppBar' import { AppBar } from '../components/AppBar/AppBar'
import { restoreState, setAuthState, setMetadataEvent } from '../store/actions' import { restoreState, setAuthState, setMetadataEvent } from '../store/actions'
import { import {
clearAuthToken, clearAuthToken,
clearState, clearState,
getRoboHashPicture,
loadState, loadState,
saveNsecBunkerDelegatedKey saveNsecBunkerDelegatedKey
} from '../utils' } from '../utils'
@ -15,12 +16,15 @@ import { LoadingSpinner } from '../components/LoadingSpinner'
import { Dispatch } from '../store/store' import { Dispatch } from '../store/store'
import { MetadataController, NostrController } from '../controllers' import { MetadataController, NostrController } from '../controllers'
import { LoginMethods } from '../store/auth/types' import { LoginMethods } from '../store/auth/types'
import { setUserRobotImage } from '../store/userRobotImage/action'
import { State } from '../store/rootReducer'
const metadataController = new MetadataController() const metadataController = new MetadataController()
export const MainLayout = () => { export const MainLayout = () => {
const dispatch: Dispatch = useDispatch() const dispatch: Dispatch = useDispatch()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const authState = useSelector((state: State) => state.auth)
useEffect(() => { useEffect(() => {
const logout = () => { const logout = () => {
@ -70,6 +74,21 @@ export const MainLayout = () => {
setIsLoading(false) setIsLoading(false)
}, [dispatch]) }, [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 <LoadingSpinner desc="Loading App" /> if (isLoading) return <LoadingSpinner desc="Loading App" />
return ( return (

View File

@ -14,7 +14,8 @@ store.subscribe(
_.throttle(() => { _.throttle(() => {
saveState({ saveState({
auth: store.getState().auth, auth: store.getState().auth,
metadata: store.getState().metadata metadata: store.getState().metadata,
userRobotImage: store.getState().userRobotImage
}) })
}, 1000) }, 1000)
) )

View File

@ -43,6 +43,16 @@ export const Login = () => {
}, 500) }, 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 () => { const loginWithExtension = async () => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Capturing pubkey from nostr extension') setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
@ -303,6 +313,7 @@ export const Login = () => {
<div className={styles.loginPage}> <div className={styles.loginPage}>
<Typography variant="h4">Welcome to Sigit</Typography> <Typography variant="h4">Welcome to Sigit</Typography>
<TextField <TextField
onKeyDown={handleInputKeyDown}
label="nip05 login / nip46 bunker string / nsec" label="nip05 login / nip46 bunker string / nsec"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}

View File

@ -1,6 +1,5 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { import {
CircularProgress,
IconButton, IconButton,
InputProps, InputProps,
List, List,
@ -12,7 +11,7 @@ import {
useTheme useTheme
} from '@mui/material' } from '@mui/material'
import { UnsignedEvent, nip19, kinds, VerifiedEvent } from 'nostr-tools' 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 { Link, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { MetadataController, NostrController } from '../../controllers' import { MetadataController, NostrController } from '../../controllers'
@ -26,6 +25,7 @@ import { setMetadataEvent } from '../../store/actions'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { LoginMethods } from '../../store/auth/types' import { LoginMethods } from '../../store/auth/types'
import { SmartToy } from '@mui/icons-material' import { SmartToy } from '@mui/icons-material'
import { getRoboHashPicture } from '../../utils'
export const ProfilePage = () => { export const ProfilePage = () => {
const theme = useTheme() const theme = useTheme()
@ -42,16 +42,18 @@ export const ProfilePage = () => {
useState<NostrJoiningBlock | null>(null) useState<NostrJoiningBlock | null>(null)
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>() const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
const [avatarLoading, setAvatarLoading] = useState(false)
const metadataState = useSelector((state: State) => state.metadata) const metadataState = useSelector((state: State) => state.metadata)
const keys = useSelector((state: State) => state.auth?.keyPair) const keys = useSelector((state: State) => state.auth?.keyPair)
const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth) const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth)
const userRobotImage = useSelector((state: State) => state.userRobotImage)
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc] = useState('Fetching metadata') const [loadingSpinnerDesc] = useState('Fetching metadata')
const robotSet = useRef(1)
useEffect(() => { useEffect(() => {
if (npub) { if (npub) {
try { try {
@ -210,22 +212,21 @@ export const ProfilePage = () => {
setSavingProfileMetadata(false) 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 = () => { 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: ''
}))
setTimeout(() => {
setProfileMetadata((prev) => ({ setProfileMetadata((prev) => ({
...prev, ...prev,
picture: robotAvatarLink picture: robotAvatarLink
})) }))
})
} }
/** /**
@ -233,21 +234,31 @@ export const ProfilePage = () => {
* @returns robohash generate button, loading spinner or no button * @returns robohash generate button, loading spinner or no button
*/ */
const robohashButton = () => { const robohashButton = () => {
if (profileMetadata?.picture?.includes('robohash')) return null
return ( return (
<Tooltip title="Generate a robohash avatar"> <Tooltip title="Generate a robohash avatar">
{avatarLoading ? (
<CircularProgress style={{ padding: 8 }} size={22} />
) : (
<IconButton onClick={generateRobotAvatar}> <IconButton onClick={generateRobotAvatar}>
<SmartToy /> <SmartToy />
</IconButton> </IconButton>
)}
</Tooltip> </Tooltip>
) )
} }
/**
* 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 ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
@ -280,11 +291,11 @@ export const ProfilePage = () => {
}} }}
> >
<img <img
onLoad={() => { onError={(event: any) => {
setAvatarLoading(false) event.target.src = getRoboHashPicture(npub!)
}} }}
className={styles.img} className={styles.img}
src={profileMetadata.picture} src={getProfileImage(profileMetadata)}
alt="Profile Image" alt="Profile Image"
/> />
@ -305,7 +316,7 @@ export const ProfilePage = () => {
</ListItem> </ListItem>
{editItem('picture', 'Picture URL', undefined, undefined, { {editItem('picture', 'Picture URL', undefined, undefined, {
endAdornment: robohashButton() endAdornment: isUsersOwnProfile ? robohashButton() : undefined
})} })}
{editItem('name', 'Username')} {editItem('name', 'Username')}

View File

@ -7,3 +7,5 @@ export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY'
export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS' export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS'
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT' export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE'

View File

@ -3,13 +3,16 @@ import { combineReducers } from 'redux'
import authReducer from './auth/reducer' import authReducer from './auth/reducer'
import { AuthState } from './auth/types' import { AuthState } from './auth/types'
import metadataReducer from './metadata/reducer' import metadataReducer from './metadata/reducer'
import userRobotImageReducer from './userRobotImage/reducer'
export interface State { export interface State {
auth: AuthState auth: AuthState
metadata?: Event metadata?: Event
userRobotImage?: string
} }
export default combineReducers({ export default combineReducers({
auth: authReducer, auth: authReducer,
metadata: metadataReducer metadata: metadataReducer,
userRobotImage: userRobotImageReducer
}) })

View File

@ -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
})

View File

@ -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

View File

@ -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

View File

@ -143,7 +143,11 @@ export const base64DecodeAuthToken = (authToken: string): SignedEvent => {
* @param pubkey in hex or npub format * @param pubkey in hex or npub format
* @returns robohash.org url for the avatar * @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) const npub = hexToNpub(pubkey)
return `https://robohash.org/${npub}.png?set=set3` return `https://robohash.org/${npub}.png?set=set${set}`
} }