feat(profile): picture upload, robohash, website, npub cash
This commit is contained in:
parent
2c2eeba83f
commit
041bd0daff
13
package-lock.json
generated
13
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"@reduxjs/toolkit": "2.2.1",
|
"@reduxjs/toolkit": "2.2.1",
|
||||||
"axios": "1.6.7",
|
"axios": "1.6.7",
|
||||||
"crypto-hash": "3.0.0",
|
"crypto-hash": "3.0.0",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"jszip": "3.10.1",
|
"jszip": "3.10.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
@ -31,6 +32,7 @@
|
|||||||
"tseep": "1.2.1"
|
"tseep": "1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/lodash": "4.14.202",
|
"@types/lodash": "4.14.202",
|
||||||
"@types/react": "^18.2.56",
|
"@types/react": "^18.2.56",
|
||||||
@ -1938,6 +1940,12 @@
|
|||||||
"@babel/types": "^7.20.7"
|
"@babel/types": "^7.20.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/crypto-js": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
||||||
@ -2630,6 +2638,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crypto-js": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
"@reduxjs/toolkit": "2.2.1",
|
"@reduxjs/toolkit": "2.2.1",
|
||||||
"axios": "1.6.7",
|
"axios": "1.6.7",
|
||||||
"crypto-hash": "3.0.0",
|
"crypto-hash": "3.0.0",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"jszip": "3.10.1",
|
"jszip": "3.10.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
@ -34,6 +35,7 @@
|
|||||||
"tseep": "1.2.1"
|
"tseep": "1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/lodash": "4.14.202",
|
"@types/lodash": "4.14.202",
|
||||||
"@types/react": "^18.2.56",
|
"@types/react": "^18.2.56",
|
||||||
|
80
src/controllers/ApiController.ts
Normal file
80
src/controllers/ApiController.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import axios, { AxiosError, AxiosInstance } from "axios"
|
||||||
|
import { PostMediaResponse } from "../types/media"
|
||||||
|
import sha256 from 'crypto-js/sha256'
|
||||||
|
import Hex from 'crypto-js/enc-hex'
|
||||||
|
import { NostrController } from "."
|
||||||
|
|
||||||
|
export class ApiController {
|
||||||
|
private mediaUrl: string = 'https://media.nquiz.io'
|
||||||
|
private api: AxiosInstance
|
||||||
|
private baseUrl: string = ''
|
||||||
|
|
||||||
|
private nostrController = NostrController.getInstance()
|
||||||
|
|
||||||
|
constructor(baseUrl?: string) {
|
||||||
|
this.baseUrl = baseUrl ?? this.baseUrl
|
||||||
|
|
||||||
|
this.api = axios.create({
|
||||||
|
withCredentials: true,
|
||||||
|
baseURL: this.baseUrl,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadMediaImage = async (
|
||||||
|
file: Buffer,
|
||||||
|
): Promise<PostMediaResponse> => {
|
||||||
|
const mediaUrl = this.mediaUrl
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
const imageBlob = new Blob([file])
|
||||||
|
formData.append('mediafile', imageBlob)
|
||||||
|
|
||||||
|
const endpointUrl = 'v1/media'
|
||||||
|
const tags = [
|
||||||
|
['u', `${mediaUrl}/${endpointUrl}`.replace('https', 'http')], //nostrcheck api, gets http protocol because it is inside a docker container in localhost
|
||||||
|
['method', 'POST'],
|
||||||
|
['payload', this.hashString('{}')]
|
||||||
|
]
|
||||||
|
|
||||||
|
const authToken = await this.nostrController.createNostrHttpAuthToken(tags)
|
||||||
|
|
||||||
|
console.info('Auth token created for media server.', authToken)
|
||||||
|
|
||||||
|
return axios
|
||||||
|
.post<PostMediaResponse>(`${mediaUrl}/api/v1/media`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
Authorization: `Nostr ${authToken}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
console.info(
|
||||||
|
`POST ${mediaUrl}/api/v1/media | res.data:`,
|
||||||
|
res.data
|
||||||
|
)
|
||||||
|
// Create correct api url, because res.url is not including 'api/v1/'
|
||||||
|
if (!res || !res.data) {
|
||||||
|
console.warn('Image upload: no data returned after upload')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = res.data
|
||||||
|
data.directLink = `${data.url.replace(mediaUrl, `${mediaUrl}/api/v1`)}`
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
.catch((err: AxiosError) => {
|
||||||
|
console.info(`POST ${mediaUrl}/api/v1/media | err:`, err)
|
||||||
|
throw {
|
||||||
|
code: 500,
|
||||||
|
message: err.response?.data || err.response || err.message || err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hashString = (payload: any): string => {
|
||||||
|
return sha256(payload).toString(Hex)
|
||||||
|
}
|
||||||
|
}
|
@ -359,4 +359,39 @@ export class NostrController extends EventEmitter {
|
|||||||
generateDelegatedKey = (): string => {
|
generateDelegatedKey = (): string => {
|
||||||
return NDKPrivateKeySigner.generate().privateKey!
|
return NDKPrivateKeySigner.generate().privateKey!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Nostr HTTP Auth token
|
||||||
|
* @param npub npub in hex format
|
||||||
|
* @param nostrTags tags to be included in the authevent (auth token)
|
||||||
|
*/
|
||||||
|
createNostrHttpAuthToken = async (
|
||||||
|
nostrTags: string[][] = []
|
||||||
|
): Promise<string> => {
|
||||||
|
const createdAt = Math.round(Date.now() / 1000)
|
||||||
|
|
||||||
|
let authEvent = new NDKEvent(undefined)
|
||||||
|
authEvent.kind = 27235
|
||||||
|
authEvent.tags = nostrTags
|
||||||
|
authEvent.content = `sigit-${createdAt}`
|
||||||
|
authEvent.created_at = createdAt
|
||||||
|
|
||||||
|
await this.signEvent(authEvent.rawEvent() as UnsignedEvent)
|
||||||
|
|
||||||
|
console.info('Signed auth event')
|
||||||
|
|
||||||
|
const base64Encoded = this.base64EncodeSignedEvent(authEvent.rawEvent())
|
||||||
|
|
||||||
|
return base64Encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
base64EncodeSignedEvent = (event: NostrEvent) => {
|
||||||
|
try {
|
||||||
|
const authEventSerialized = JSON.stringify(event)
|
||||||
|
const token = btoa(authEventSerialized)
|
||||||
|
return token
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('An error occurred in JSON.stringy of signedAuthEvent')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
import { List, ListItem, ListSubheader, TextField } from '@mui/material'
|
import { CircularProgress, List, ListItem, ListSubheader, TextField } 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, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
@ -15,6 +15,8 @@ import { Dispatch } from '../../store/store'
|
|||||||
import { setMetadataEvent } from '../../store/actions'
|
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 { ApiController } from '../../controllers/ApiController'
|
||||||
|
import { PostMediaResponse } from '../../types/media'
|
||||||
|
|
||||||
export const ProfilePage = () => {
|
export const ProfilePage = () => {
|
||||||
const { npub } = useParams()
|
const { npub } = useParams()
|
||||||
@ -27,9 +29,11 @@ export const ProfilePage = () => {
|
|||||||
const [pubkey, setPubkey] = useState<string>()
|
const [pubkey, setPubkey] = useState<string>()
|
||||||
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 [uploadingImage, setUploadingImage] = useState<boolean>()
|
||||||
|
|
||||||
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
|
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
|
||||||
|
|
||||||
@ -55,6 +59,10 @@ export const ProfilePage = () => {
|
|||||||
metadataState as VerifiedEvent
|
metadataState as VerifiedEvent
|
||||||
)
|
)
|
||||||
if (metadataContent) {
|
if (metadataContent) {
|
||||||
|
if (!metadataContent.lud16 || metadataContent.lud16.length < 1) {
|
||||||
|
metadataContent.lud16 = getLud16()
|
||||||
|
}
|
||||||
|
|
||||||
setProfileMetadata(metadataContent)
|
setProfileMetadata(metadataContent)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@ -74,7 +82,13 @@ export const ProfilePage = () => {
|
|||||||
if (metadataEvent) {
|
if (metadataEvent) {
|
||||||
const metadataContent =
|
const metadataContent =
|
||||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||||
if (metadataContent) setProfileMetadata(metadataContent)
|
if (metadataContent) {
|
||||||
|
if (!metadataContent.lud16 || metadataContent.lud16.length < 1) {
|
||||||
|
metadataContent.lud16 = getLud16()
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileMetadata(metadataContent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@ -84,6 +98,16 @@ export const ProfilePage = () => {
|
|||||||
}
|
}
|
||||||
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
|
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
|
||||||
|
|
||||||
|
const getLud16 = () => {
|
||||||
|
let lud16 = ''
|
||||||
|
|
||||||
|
if (npub) {
|
||||||
|
if (npub) lud16 = `${npub}@npub.cash`
|
||||||
|
}
|
||||||
|
|
||||||
|
return lud16
|
||||||
|
}
|
||||||
|
|
||||||
const editItem = (
|
const editItem = (
|
||||||
key: keyof ProfileMetadata,
|
key: keyof ProfileMetadata,
|
||||||
label: string,
|
label: string,
|
||||||
@ -178,6 +202,59 @@ export const ProfilePage = () => {
|
|||||||
setSavingProfileMetadata(false)
|
setSavingProfileMetadata(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onProfileImageChange = (event: any) => {
|
||||||
|
const file = event?.target?.files[0]
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
setUploadingImage(true)
|
||||||
|
|
||||||
|
const apiController = new ApiController()
|
||||||
|
|
||||||
|
apiController.uploadMediaImage(file).then((imageResponse: PostMediaResponse) => {
|
||||||
|
if (imageResponse && imageResponse.directLink) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setProfileMetadata((prev) => ({
|
||||||
|
...prev,
|
||||||
|
picture: imageResponse.directLink
|
||||||
|
}))
|
||||||
|
}, 200)
|
||||||
|
} else {
|
||||||
|
toast.error(`Uploading image failed. Please try again.`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Error uploading image to media server.', err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setUploadingImage(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressSpinner = (show = false) => {
|
||||||
|
if (!show) return undefined
|
||||||
|
|
||||||
|
return <CircularProgress size={20} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateRobotAvatar = () => {
|
||||||
|
setAvatarLoading(true)
|
||||||
|
|
||||||
|
const robotAvatarLink = `https://robohash.org/${npub}.png?set=set3`
|
||||||
|
|
||||||
|
setProfileMetadata((prev) => ({
|
||||||
|
...prev,
|
||||||
|
picture: ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setProfileMetadata((prev) => ({
|
||||||
|
...prev,
|
||||||
|
picture: robotAvatarLink
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||||
@ -214,10 +291,27 @@ export const ProfilePage = () => {
|
|||||||
onError={(event: any) => {
|
onError={(event: any) => {
|
||||||
event.target.src = placeholderAvatar
|
event.target.src = placeholderAvatar
|
||||||
}}
|
}}
|
||||||
|
onLoad={() => {
|
||||||
|
setAvatarLoading(false)
|
||||||
|
}}
|
||||||
className={styles.img}
|
className={styles.img}
|
||||||
src={profileMetadata.picture || placeholderAvatar}
|
src={profileMetadata.picture || placeholderAvatar}
|
||||||
alt='Profile Image'
|
alt='Profile Image'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LoadingButton onClick={generateRobotAvatar} loading={avatarLoading}>Generate Robot Avatar</LoadingButton>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
onChange={onProfileImageChange}
|
||||||
|
type="file"
|
||||||
|
size="small"
|
||||||
|
label="Profile Image"
|
||||||
|
className={styles.textField}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: progressSpinner(uploadingImage)
|
||||||
|
}}
|
||||||
|
focused
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{editItem('name', 'Username')}
|
{editItem('name', 'Username')}
|
||||||
@ -225,6 +319,7 @@ export const ProfilePage = () => {
|
|||||||
{editItem('nip05', 'Nostr Address (nip05)')}
|
{editItem('nip05', 'Nostr Address (nip05)')}
|
||||||
{editItem('lud16', 'Lightning Address (lud16)')}
|
{editItem('lud16', 'Lightning Address (lud16)')}
|
||||||
{editItem('about', 'About', true, 4)}
|
{editItem('about', 'About', true, 4)}
|
||||||
|
{editItem('website', 'Website')}
|
||||||
{isUsersOwnProfile && (
|
{isUsersOwnProfile && (
|
||||||
<>
|
<>
|
||||||
{usersPubkey &&
|
{usersPubkey &&
|
||||||
|
19
src/types/media.ts
Normal file
19
src/types/media.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export interface PostMediaResponse {
|
||||||
|
result: boolean
|
||||||
|
description: string
|
||||||
|
status: string
|
||||||
|
id: number
|
||||||
|
pubkey: string
|
||||||
|
url: string
|
||||||
|
directLink: string
|
||||||
|
hash: string
|
||||||
|
magnet: string
|
||||||
|
tags: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetMediaResponse extends PostMediaResponse {}
|
||||||
|
|
||||||
|
export interface MediaErrorResponse {
|
||||||
|
description: string
|
||||||
|
result: boolean
|
||||||
|
}
|
@ -3,6 +3,7 @@ export interface ProfileMetadata {
|
|||||||
display_name?: string
|
display_name?: string
|
||||||
picture?: string
|
picture?: string
|
||||||
about?: string
|
about?: string
|
||||||
|
website?: string
|
||||||
nip05?: string
|
nip05?: string
|
||||||
lud16?: string
|
lud16?: string
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user