New Release #98
54
package-lock.json
generated
54
package-lock.json
generated
@ -21,6 +21,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dnd-core": "16.0.1",
|
||||
"file-saver": "2.0.5",
|
||||
"idb": "8.0.0",
|
||||
"jszip": "3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"mui-file-input": "4.0.4",
|
||||
@ -50,7 +51,8 @@
|
||||
"prettier": "3.2.5",
|
||||
"ts-css-modules-vite-plugin": "1.0.20",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.4"
|
||||
"vite": "^5.1.4",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
@ -3554,6 +3556,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
@ -3592,6 +3600,11 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz",
|
||||
"integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw=="
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
|
||||
@ -4992,6 +5005,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfck": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.0.tgz",
|
||||
"integrity": "sha512-CMjc5zMnyAjcS9sPLytrbFmj89st2g+JYtY/c02ug4Q+CZaAtCgbyviI0n1YvjZE/pzoc6FbNsINS13DOL1B9w==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsconfck": "bin/tsconfck.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tseep": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tseep/-/tseep-1.2.1.tgz",
|
||||
@ -5197,6 +5230,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-tsconfig-paths": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz",
|
||||
"integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"globrex": "^0.1.2",
|
||||
"tsconfck": "^3.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
|
@ -27,6 +27,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dnd-core": "16.0.1",
|
||||
"file-saver": "2.0.5",
|
||||
"idb": "8.0.0",
|
||||
"jszip": "3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"mui-file-input": "4.0.4",
|
||||
@ -56,6 +57,7 @@
|
||||
"prettier": "3.2.5",
|
||||
"ts-css-modules-vite-plugin": "1.0.20",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.4"
|
||||
"vite": "^5.1.4",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
} from '../../routes'
|
||||
import {
|
||||
clearAuthToken,
|
||||
hexToNpub,
|
||||
saveNsecBunkerDelegatedKey,
|
||||
shorten
|
||||
} from '../../utils'
|
||||
@ -60,13 +61,17 @@ export const AppBar = () => {
|
||||
setUserAvatar(picture || userRobotImage)
|
||||
}
|
||||
|
||||
setUsername(shorten(display_name || name || '', 7))
|
||||
const npub = authState.usersPubkey
|
||||
? hexToNpub(authState.usersPubkey)
|
||||
: ''
|
||||
|
||||
setUsername(shorten(display_name || name || npub, 7))
|
||||
} else {
|
||||
setUserAvatar(userRobotImage || '')
|
||||
setUsername('')
|
||||
}
|
||||
}
|
||||
}, [metadataState, userRobotImage])
|
||||
}, [metadataState, userRobotImage, authState.usersPubkey])
|
||||
|
||||
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorElUser(event.currentTarget)
|
||||
@ -172,13 +177,13 @@ export const AppBar = () => {
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
navigate(appPrivateRoutes.relays)
|
||||
navigate(appPrivateRoutes.settings)
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Relays
|
||||
Settings
|
||||
</MenuItem>
|
||||
<Link
|
||||
to={appPublicRoutes.source}
|
||||
|
@ -14,71 +14,130 @@ import { NostrController } from '.'
|
||||
import { toast } from 'react-toastify'
|
||||
import { queryNip05 } from '../utils'
|
||||
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
|
||||
import { EventEmitter } from 'tseep'
|
||||
import { localCache } from '../services'
|
||||
|
||||
export class MetadataController {
|
||||
export class MetadataController extends EventEmitter {
|
||||
private nostrController: NostrController
|
||||
private specialMetadataRelay = 'wss://purplepag.es'
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.nostrController = NostrController.getInstance()
|
||||
}
|
||||
|
||||
public getEmptyMetadataEvent = (): Event => {
|
||||
return {
|
||||
content: '',
|
||||
created_at: new Date().valueOf(),
|
||||
id: '',
|
||||
kind: 0,
|
||||
pubkey: '',
|
||||
sig: '',
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
|
||||
public findMetadata = async (hexKey: string) => {
|
||||
/**
|
||||
* Asynchronously checks for more recent metadata events authored by a specific key.
|
||||
* If a more recent metadata event is found, it is handled and returned.
|
||||
* If no more recent event is found, the current event is returned.
|
||||
* @param hexKey The hexadecimal key of the author to filter metadata events.
|
||||
* @param currentEvent The current metadata event, if any, to compare with newer events.
|
||||
* @returns A promise resolving to the most recent metadata event found, or null if none is found.
|
||||
*/
|
||||
private async checkForMoreRecentMetadata(
|
||||
hexKey: string,
|
||||
currentEvent: Event | null
|
||||
): Promise<Event | null> {
|
||||
// Define the event filter to only include metadata events authored by the given key
|
||||
const eventFilter: Filter = {
|
||||
kinds: [kinds.Metadata],
|
||||
authors: [hexKey]
|
||||
kinds: [kinds.Metadata], // Only metadata events
|
||||
authors: [hexKey] // Authored by the specified key
|
||||
}
|
||||
|
||||
const pool = new SimplePool()
|
||||
|
||||
// Try to get the metadata event from a special relay (wss://purplepag.es)
|
||||
const metadataEvent = await pool
|
||||
.get([this.specialMetadataRelay], eventFilter)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
return null
|
||||
console.error(err) // Log any errors
|
||||
return null // Return null if an error occurs
|
||||
})
|
||||
|
||||
// If a valid metadata event is found from the special relay
|
||||
if (
|
||||
metadataEvent &&
|
||||
validateEvent(metadataEvent) &&
|
||||
verifyEvent(metadataEvent)
|
||||
validateEvent(metadataEvent) && // Validate the event
|
||||
verifyEvent(metadataEvent) // Verify the event's authenticity
|
||||
) {
|
||||
// If there's no current event or the new metadata event is more recent
|
||||
if (!currentEvent || metadataEvent.created_at > currentEvent.created_at) {
|
||||
// Handle the new metadata event
|
||||
this.handleNewMetadataEvent(metadataEvent)
|
||||
return metadataEvent
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid metadata event is found from the special relay, get the most popular relays
|
||||
const mostPopularRelays = await this.nostrController.getMostPopularRelays()
|
||||
|
||||
// Query the most popular relays for metadata events
|
||||
const events = await pool
|
||||
.querySync(mostPopularRelays, eventFilter)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
|
||||
return null
|
||||
console.error(err) // Log any errors
|
||||
return null // Return null if an error occurs
|
||||
})
|
||||
|
||||
// If events are found from the popular relays
|
||||
if (events && events.length) {
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
events.sort((a, b) => b.created_at - a.created_at) // Sort events by creation date (descending)
|
||||
|
||||
// Iterate through the events
|
||||
for (const event of events) {
|
||||
if (validateEvent(event) && verifyEvent(event)) {
|
||||
// If the event is valid, authentic, and more recent than the current event
|
||||
if (
|
||||
validateEvent(event) &&
|
||||
verifyEvent(event) &&
|
||||
(!currentEvent || event.created_at > currentEvent.created_at)
|
||||
) {
|
||||
// Handle the new metadata event
|
||||
this.handleNewMetadataEvent(event)
|
||||
return event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Mo metadata found.')
|
||||
return currentEvent // Return the current event if no newer event is found
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new metadata events and emit them to subscribers
|
||||
*/
|
||||
private async handleNewMetadataEvent(event: VerifiedEvent) {
|
||||
// update the event in local cache
|
||||
localCache.addUserMetadata(event)
|
||||
// Emit the event to subscribers.
|
||||
this.emit(event.pubkey, event.kind, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds metadata for a given hexadecimal key.
|
||||
*
|
||||
* @param hexKey - The hexadecimal key to search for metadata.
|
||||
* @returns A promise that resolves to the metadata event.
|
||||
*/
|
||||
public findMetadata = async (hexKey: string): Promise<Event | null> => {
|
||||
// Attempt to retrieve the metadata event from the local cache
|
||||
const cachedMetadataEvent = await localCache.getUserMetadata(hexKey)
|
||||
|
||||
// If cached metadata is found, check its validity
|
||||
if (cachedMetadataEvent) {
|
||||
const oneDayInMS = 24 * 60 * 60 * 1000 // Number of milliseconds in one day
|
||||
|
||||
// Check if the cached metadata is older than one day
|
||||
if (Date.now() - cachedMetadataEvent.cachedAt > oneDayInMS) {
|
||||
// If older than one day, find the metadata from relays in background
|
||||
|
||||
this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event)
|
||||
}
|
||||
|
||||
// Return the cached metadata event
|
||||
return cachedMetadataEvent.event
|
||||
}
|
||||
|
||||
// If no cached metadata is found, retrieve it from relays
|
||||
return this.checkForMoreRecentMetadata(hexKey, null)
|
||||
}
|
||||
|
||||
public findRelayListMetadata = async (hexKey: string) => {
|
||||
@ -142,7 +201,7 @@ export class MetadataController {
|
||||
throw new Error('No relay list metadata found.')
|
||||
}
|
||||
|
||||
public extractProfileMetadataContent = (event: VerifiedEvent) => {
|
||||
public extractProfileMetadataContent = (event: Event) => {
|
||||
try {
|
||||
if (!event.content) return {}
|
||||
return JSON.parse(event.content) as ProfileMetadata
|
||||
@ -175,6 +234,7 @@ export class MetadataController {
|
||||
.publishEvent(signedMetadataEvent, [this.specialMetadataRelay])
|
||||
.then((relays) => {
|
||||
toast.success(`Metadata event published on: ${relays.join('\n')}`)
|
||||
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message)
|
||||
@ -193,6 +253,8 @@ export class MetadataController {
|
||||
userRelays.push(...relaySet.write)
|
||||
} else {
|
||||
const metadata = await this.findMetadata(hexKey)
|
||||
if (!metadata) return null
|
||||
|
||||
const metadataContent = this.extractProfileMetadataContent(metadata)
|
||||
|
||||
if (metadataContent?.nip05) {
|
||||
@ -306,4 +368,16 @@ export class MetadataController {
|
||||
}
|
||||
|
||||
public validate = (event: Event) => validateEvent(event) && verifyEvent(event)
|
||||
|
||||
public getEmptyMetadataEvent = (): Event => {
|
||||
return {
|
||||
content: '',
|
||||
created_at: new Date().valueOf(),
|
||||
id: '',
|
||||
kind: 0,
|
||||
pubkey: '',
|
||||
sig: '',
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,8 +18,7 @@ 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()
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
|
||||
export const MainLayout = () => {
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
@ -27,6 +26,8 @@ export const MainLayout = () => {
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
|
||||
useEffect(() => {
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
const logout = () => {
|
||||
dispatch(
|
||||
setAuthState({
|
||||
@ -68,6 +69,20 @@ export const MainLayout = () => {
|
||||
nostrController.createNsecBunkerSigner(usersPubkey)
|
||||
})
|
||||
}
|
||||
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
dispatch(setMetadataEvent(event))
|
||||
}
|
||||
|
||||
metadataController.on(usersPubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController.findMetadata(usersPubkey).then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,6 +35,7 @@ import {
|
||||
generateEncryptionKey,
|
||||
getHash,
|
||||
hexToNpub,
|
||||
isOnline,
|
||||
npubToHex,
|
||||
queryNip05,
|
||||
sendDM,
|
||||
@ -49,6 +50,7 @@ import type { Identifier, XYCoord } from 'dnd-core'
|
||||
import { useDrag, useDrop } from 'react-dnd'
|
||||
import saveAs from 'file-saver'
|
||||
import CopyModal from '../../components/copyModal'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
|
||||
export const CreatePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -337,7 +339,7 @@ export const CreatePage = () => {
|
||||
|
||||
const blob = new Blob([encryptedArrayBuffer])
|
||||
|
||||
if (navigator.onLine) {
|
||||
if (await isOnline()) {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Uploading zip file to file storage.')
|
||||
const fileUrl = await uploadToFileStorage(blob, nostrController)
|
||||
@ -517,16 +519,26 @@ const DisplayUser = ({
|
||||
if (!(user.pubkey in metadata)) {
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
metadataController
|
||||
.findMetadata(user.pubkey)
|
||||
.then((metadataEvent) => {
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
if (metadataContent)
|
||||
setMetadata((prev) => ({
|
||||
...prev,
|
||||
[user.pubkey]: metadataContent
|
||||
}))
|
||||
}
|
||||
|
||||
metadataController.on(user.pubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController
|
||||
.findMetadata(user.pubkey)
|
||||
.then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
|
@ -2,7 +2,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material'
|
||||
import { truncate } from 'lodash'
|
||||
import { VerifiedEvent, nip19 } from 'nostr-tools'
|
||||
import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
@ -75,6 +75,20 @@ export const ProfilePage = () => {
|
||||
|
||||
if (pubkey) {
|
||||
const getMetadata = async (pubkey: string) => {
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
}
|
||||
}
|
||||
|
||||
metadataController.on(pubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch((err) => {
|
||||
@ -82,13 +96,7 @@ export const ProfilePage = () => {
|
||||
return null
|
||||
})
|
||||
|
||||
if (metadataEvent) {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
}
|
||||
}
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
96
src/pages/settings/Settings.tsx
Normal file
96
src/pages/settings/Settings.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle'
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
|
||||
import CachedIcon from '@mui/icons-material/Cached'
|
||||
import RouterIcon from '@mui/icons-material/Router'
|
||||
import { useTheme } from '@mui/material'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import ListSubheader from '@mui/material/ListSubheader'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
|
||||
const listItem = (label: string, disabled = false) => {
|
||||
return (
|
||||
<>
|
||||
<ListItemText
|
||||
primary={label}
|
||||
sx={{
|
||||
color: theme.palette.text.primary
|
||||
}}
|
||||
/>
|
||||
|
||||
{!disabled && (
|
||||
<ArrowForwardIosIcon
|
||||
style={{
|
||||
color: theme.palette.action.active,
|
||||
marginRight: -10
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper',
|
||||
marginTop: 2
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(getProfileSettingsRoute(usersPubkey!))
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Profile')}
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(appPrivateRoutes.relays)
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<RouterIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Relays')}
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(appPrivateRoutes.cacheSettings)
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<CachedIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Local Cache')}
|
||||
</ListItemButton>
|
||||
</List>
|
||||
)
|
||||
}
|
96
src/pages/settings/cache/index.tsx
vendored
Normal file
96
src/pages/settings/cache/index.tsx
vendored
Normal file
@ -0,0 +1,96 @@
|
||||
import ClearIcon from '@mui/icons-material/Clear'
|
||||
import InputIcon from '@mui/icons-material/Input'
|
||||
import IosShareIcon from '@mui/icons-material/IosShare'
|
||||
import {
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { localCache } from '../../../services'
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
|
||||
export const CacheSettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const handleClearData = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Clearing cache data')
|
||||
localCache
|
||||
.clearCacheData()
|
||||
.then(() => {
|
||||
toast.success('cleared cached data')
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('An error occurred in clearing cache data', err)
|
||||
toast.error(err.message || 'An error occurred in clearing cache data')
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const listItem = (label: string) => {
|
||||
return (
|
||||
<ListItemText
|
||||
primary={label}
|
||||
sx={{
|
||||
color: theme.palette.text.primary
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper',
|
||||
marginTop: 2
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2
|
||||
}}
|
||||
>
|
||||
Cache Setting
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton disabled>
|
||||
<ListItemIcon>
|
||||
<IosShareIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Export (coming soon)')}
|
||||
</ListItemButton>
|
||||
|
||||
<ListItemButton disabled>
|
||||
<ListItemIcon>
|
||||
<InputIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Import (coming soon)')}
|
||||
</ListItemButton>
|
||||
|
||||
<ListItemButton onClick={handleClearData}>
|
||||
<ListItemIcon>
|
||||
<ClearIcon sx={{ color: theme.palette.error.main }} />
|
||||
</ListItemIcon>
|
||||
{listItem('Clear Cache')}
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</>
|
||||
)
|
||||
}
|
@ -11,7 +11,7 @@ import {
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
import { UnsignedEvent, nip19, kinds, VerifiedEvent } from 'nostr-tools'
|
||||
import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
@ -95,6 +95,20 @@ export const ProfileSettingsPage = () => {
|
||||
|
||||
if (pubkey) {
|
||||
const getMetadata = async (pubkey: string) => {
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
}
|
||||
}
|
||||
|
||||
metadataController.on(pubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch((err) => {
|
||||
@ -102,13 +116,7 @@ export const ProfileSettingsPage = () => {
|
||||
return null
|
||||
})
|
||||
|
||||
if (metadataEvent) {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
}
|
||||
}
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
@ -1,37 +1,36 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, List, ListItem, TextField } from '@mui/material'
|
||||
import RouterIcon from '@mui/icons-material/Router'
|
||||
import styles from './style.module.scss'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import { NostrController } from '../../controllers'
|
||||
import {
|
||||
RelayMap,
|
||||
RelayInfoObject,
|
||||
RelayFee,
|
||||
RelayConnectionState
|
||||
} from '../../types'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import ElectricBoltIcon from '@mui/icons-material/ElectricBolt'
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
import { useAppSelector, useAppDispatch } from '../../hooks'
|
||||
import {
|
||||
compareObjects,
|
||||
shorten,
|
||||
hexToNpub,
|
||||
capitalizeFirstLetter
|
||||
} from '../../utils'
|
||||
import RouterIcon from '@mui/icons-material/Router'
|
||||
import { Box, List, ListItem, TextField, Tooltip } from '@mui/material'
|
||||
import Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { NostrController } from '../../../controllers'
|
||||
import { useAppDispatch, useAppSelector } from '../../../hooks'
|
||||
import {
|
||||
setRelayMapAction,
|
||||
setRelayMapUpdatedAction
|
||||
} from '../../store/actions'
|
||||
import { toast } from 'react-toastify'
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import ElectricBoltIcon from '@mui/icons-material/ElectricBolt'
|
||||
import { Tooltip } from '@mui/material'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Button from '@mui/material/Button'
|
||||
} from '../../../store/actions'
|
||||
import {
|
||||
RelayConnectionState,
|
||||
RelayFee,
|
||||
RelayInfoObject,
|
||||
RelayMap
|
||||
} from '../../../types'
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
compareObjects,
|
||||
hexToNpub,
|
||||
shorten
|
||||
} from '../../../utils'
|
||||
import styles from './style.module.scss'
|
||||
|
||||
export const RelaysPage = () => {
|
||||
const nostrController = NostrController.getInstance()
|
@ -1,4 +1,4 @@
|
||||
@import '../../colors.scss';
|
||||
@import '../../../colors.scss';
|
||||
|
||||
.container {
|
||||
margin-top: 25px;
|
@ -20,7 +20,7 @@ import saveAs from 'file-saver'
|
||||
import JSZip from 'jszip'
|
||||
import _ from 'lodash'
|
||||
import { MuiFileInput } from 'mui-file-input'
|
||||
import { Event, verifyEvent } from 'nostr-tools'
|
||||
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
@ -50,7 +50,8 @@ import {
|
||||
sendDM,
|
||||
shorten,
|
||||
signEventForMetaFile,
|
||||
uploadToFileStorage
|
||||
uploadToFileStorage,
|
||||
isOnline
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import {
|
||||
@ -433,7 +434,7 @@ export const SignPage = () => {
|
||||
|
||||
const blob = new Blob([encryptedArrayBuffer])
|
||||
|
||||
if (navigator.onLine) {
|
||||
if (await isOnline()) {
|
||||
setLoadingSpinnerDesc('Uploading zip file to file storage.')
|
||||
const fileUrl = await uploadToFileStorage(blob, nostrController)
|
||||
.then((url) => {
|
||||
@ -846,17 +847,27 @@ const DisplayMeta = ({
|
||||
|
||||
hexKeys.forEach((key) => {
|
||||
if (!(key in metadata)) {
|
||||
metadataController
|
||||
.findMetadata(key)
|
||||
.then((metadataEvent) => {
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
|
||||
if (metadataContent)
|
||||
setMetadata((prev) => ({
|
||||
...prev,
|
||||
[key]: metadataContent
|
||||
}))
|
||||
}
|
||||
|
||||
metadataController.on(key, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController
|
||||
.findMetadata(key)
|
||||
.then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`error occurred in finding metadata for: ${key}`, err)
|
||||
@ -902,7 +913,9 @@ const DisplayMeta = ({
|
||||
<UserComponent
|
||||
pubkey={submittedBy}
|
||||
name={
|
||||
profile?.display_name || profile?.name || shorten(submittedBy)
|
||||
profile?.display_name ||
|
||||
profile?.name ||
|
||||
shorten(hexToNpub(submittedBy))
|
||||
}
|
||||
image={profile?.picture}
|
||||
/>
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import JSZip from 'jszip'
|
||||
import { MuiFileInput } from 'mui-file-input'
|
||||
import { Event, verifyEvent } from 'nostr-tools'
|
||||
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
@ -107,16 +107,26 @@ export const VerifyPage = () => {
|
||||
const pubkey = npubToHex(user)!
|
||||
|
||||
if (!(pubkey in metadata)) {
|
||||
metadataController
|
||||
.findMetadata(pubkey)
|
||||
.then((metadataEvent) => {
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
if (metadataContent)
|
||||
setMetadata((prev) => ({
|
||||
...prev,
|
||||
[pubkey]: metadataContent
|
||||
}))
|
||||
}
|
||||
|
||||
metadataController.on(pubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController
|
||||
.findMetadata(pubkey)
|
||||
.then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
|
@ -1,21 +1,25 @@
|
||||
import { HomePage } from '../pages/home'
|
||||
import { CreatePage } from '../pages/create'
|
||||
import { HomePage } from '../pages/home'
|
||||
import { LandingPage } from '../pages/landing/LandingPage'
|
||||
import { Login } from '../pages/login'
|
||||
import { ProfilePage } from '../pages/profile'
|
||||
import { hexToNpub } from '../utils'
|
||||
import { SettingsPage } from '../pages/settings/Settings'
|
||||
import { CacheSettingsPage } from '../pages/settings/cache'
|
||||
import { ProfileSettingsPage } from '../pages/settings/profile'
|
||||
import { RelaysPage } from '../pages/settings/relays'
|
||||
import { SignPage } from '../pages/sign'
|
||||
import { VerifyPage } from '../pages/verify'
|
||||
import { ProfileSettingsPage } from '../pages/settings/profile'
|
||||
import { RelaysPage } from '../pages/relays'
|
||||
import { hexToNpub } from '../utils'
|
||||
|
||||
export const appPrivateRoutes = {
|
||||
homePage: '/',
|
||||
create: '/create',
|
||||
sign: '/sign',
|
||||
verify: '/verify',
|
||||
settings: '/settings',
|
||||
profileSettings: '/settings/profile/:npub',
|
||||
relays: '/relays'
|
||||
cacheSettings: '/settings/cache',
|
||||
relays: '/settings/relays'
|
||||
}
|
||||
|
||||
export const appPublicRoutes = {
|
||||
@ -65,10 +69,18 @@ export const privateRoutes = [
|
||||
path: appPrivateRoutes.verify,
|
||||
element: <VerifyPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.settings,
|
||||
element: <SettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.profileSettings,
|
||||
element: <ProfileSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.cacheSettings,
|
||||
element: <CacheSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.relays,
|
||||
element: <RelaysPage />
|
||||
|
63
src/services/cache/index.ts
vendored
Normal file
63
src/services/cache/index.ts
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
import { IDBPDatabase, openDB } from 'idb'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { CachedMetadataEvent } from '../../types'
|
||||
import { SchemaV1 } from './schema'
|
||||
|
||||
class LocalCache {
|
||||
// Static property to hold the single instance of LocalCache
|
||||
private static instance: LocalCache | null = null
|
||||
private db!: IDBPDatabase<SchemaV1>
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {}
|
||||
|
||||
// Method to initialize the database
|
||||
private async init() {
|
||||
this.db = await openDB<SchemaV1>('sigit-cache', 1, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore('userMetadata', { keyPath: 'event.pubkey' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Static method to get the single instance of LocalCache
|
||||
public static async getInstance(): Promise<LocalCache> {
|
||||
// If the instance doesn't exist, create it
|
||||
if (!LocalCache.instance) {
|
||||
LocalCache.instance = new LocalCache()
|
||||
await LocalCache.instance.init()
|
||||
}
|
||||
// Return the single instance of LocalCache
|
||||
return LocalCache.instance
|
||||
}
|
||||
|
||||
// Method to add user metadata
|
||||
public async addUserMetadata(event: Event) {
|
||||
await this.db.put('userMetadata', { event, cachedAt: Date.now() })
|
||||
}
|
||||
|
||||
// Method to get user metadata by key
|
||||
public async getUserMetadata(
|
||||
key: string
|
||||
): Promise<CachedMetadataEvent | null> {
|
||||
const data = await this.db.get('userMetadata', key)
|
||||
return data || null
|
||||
}
|
||||
|
||||
// Method to delete user metadata by key
|
||||
public async deleteUserMetadata(key: string) {
|
||||
await this.db.delete('userMetadata', key)
|
||||
}
|
||||
|
||||
// Method to clear cache data
|
||||
public async clearCacheData() {
|
||||
// Clear the 'userMetadata' store in the IndexedDB database
|
||||
await this.db.clear('userMetadata')
|
||||
|
||||
// Reload the current page to ensure any cached data is reset
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// Export the single instance of LocalCache
|
||||
export const localCache = await LocalCache.getInstance()
|
9
src/services/cache/schema.ts
vendored
Normal file
9
src/services/cache/schema.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
import { DBSchema } from 'idb'
|
||||
import { CachedMetadataEvent } from '../../types'
|
||||
|
||||
export interface SchemaV1 extends DBSchema {
|
||||
userMetadata: {
|
||||
key: string
|
||||
value: CachedMetadataEvent
|
||||
}
|
||||
}
|
1
src/services/index.ts
Normal file
1
src/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './cache'
|
6
src/types/cache.ts
Normal file
6
src/types/cache.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
export interface CachedMetadataEvent {
|
||||
event: Event
|
||||
cachedAt: number
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
export * from './cache'
|
||||
export * from './core'
|
||||
export * from './nostr'
|
||||
export * from './profile'
|
||||
export * from './zip'
|
||||
export * from './relay'
|
||||
export * from './zip'
|
||||
|
@ -11,3 +11,41 @@ export const compareObjects = (
|
||||
|
||||
return JSON.stringify(obj1) === JSON.stringify(obj2)
|
||||
}
|
||||
|
||||
// Function to check if the system is online by making a network request
|
||||
export const isOnline = async () => {
|
||||
// First, check the navigator's online status
|
||||
if (!navigator.onLine) return false // If navigator reports offline, return false
|
||||
|
||||
/**
|
||||
* If navigator.onLine is true, it can be false positive.
|
||||
* In other words, navigator.onLine being true does not necessarily mean that there is a working internet connection.
|
||||
* Most implementations seem to only be checking if there's a connected network adapter.
|
||||
* There are a number of circumstances where that's the case, but the internet is still unreachable. For example:
|
||||
* The user is only connected to an internal network
|
||||
* The user is inside a virtual machine, and the virtual network adapter is connected, but the host system is offline
|
||||
* The user uses a VPN which has installed a virtual network adapter that is always connected
|
||||
*
|
||||
* To overcome the above problem we'll have to make a http request
|
||||
*/
|
||||
|
||||
try {
|
||||
// Define a URL to check the online status
|
||||
const url = 'https://www.google.com'
|
||||
|
||||
// Make a HEAD request to the URL with 'no-cors' mode
|
||||
// This mode is used to handle opaque responses which do not expose their content
|
||||
const response = await fetch(url, { method: 'HEAD', mode: 'no-cors' })
|
||||
|
||||
// Check if the response is OK or if the response type is 'opaque'
|
||||
// An 'opaque' response type means that the request succeeded but the response content is not available
|
||||
if (response.ok || response.type === 'opaque') {
|
||||
return true // If the request is successful, return true
|
||||
} else {
|
||||
return false // If the request fails, return false
|
||||
}
|
||||
} catch (error) {
|
||||
// Catch any errors that occur during the fetch request
|
||||
return false // If an error occurs, return false
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2022", // Updated to ES2022 for better top-level await support
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"], // Updated to ES2022
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
build: {
|
||||
target: 'ES2022'
|
||||
}
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user