Merge pull request 'New Release' (#98) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m7s

Reviewed-on: https://git.sigit.io/sig/it/pulls/98
Reviewed-by: Y <yury@4gl.io>
This commit is contained in:
b 2024-05-31 14:48:43 +00:00
commit 311d325fd3
24 changed files with 651 additions and 121 deletions

54
package-lock.json generated
View File

@ -21,6 +21,7 @@
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dnd-core": "16.0.1", "dnd-core": "16.0.1",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"idb": "8.0.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"mui-file-input": "4.0.4", "mui-file-input": "4.0.4",
@ -50,7 +51,8 @@
"prettier": "3.2.5", "prettier": "3.2.5",
"ts-css-modules-vite-plugin": "1.0.20", "ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.1.4" "vite": "^5.1.4",
"vite-tsconfig-paths": "4.3.2"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {
@ -3554,6 +3556,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "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", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "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": { "node_modules/ignore": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", "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": { "node_modules/tseep": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/tseep/-/tseep-1.2.1.tgz", "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": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View File

@ -27,6 +27,7 @@
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dnd-core": "16.0.1", "dnd-core": "16.0.1",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"idb": "8.0.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"mui-file-input": "4.0.4", "mui-file-input": "4.0.4",
@ -56,6 +57,7 @@
"prettier": "3.2.5", "prettier": "3.2.5",
"ts-css-modules-vite-plugin": "1.0.20", "ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.1.4" "vite": "^5.1.4",
"vite-tsconfig-paths": "4.3.2"
} }
} }

View File

@ -16,6 +16,13 @@ const App = () => {
const authState = useSelector((state: State) => state.auth) const authState = useSelector((state: State) => state.auth)
useEffect(() => { useEffect(() => {
if (window.location.hostname === '0.0.0.0') {
// A change of the host is needed to make library available in windows object
// the app can't encrypt files without the crypto library
// which is only available on https or localhost
window.location.hostname = 'localhost'
}
generateBunkerDelegatedKey() generateBunkerDelegatedKey()
const authController = new AuthController() const authController = new AuthController()

View File

@ -28,6 +28,7 @@ import {
} from '../../routes' } from '../../routes'
import { import {
clearAuthToken, clearAuthToken,
hexToNpub,
saveNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey,
shorten shorten
} from '../../utils' } from '../../utils'
@ -60,13 +61,17 @@ export const AppBar = () => {
setUserAvatar(picture || userRobotImage) setUserAvatar(picture || userRobotImage)
} }
setUsername(shorten(display_name || name || '', 7)) const npub = authState.usersPubkey
? hexToNpub(authState.usersPubkey)
: ''
setUsername(shorten(display_name || name || npub, 7))
} else { } else {
setUserAvatar(userRobotImage || '') setUserAvatar(userRobotImage || '')
setUsername('') setUsername('')
} }
} }
}, [metadataState, userRobotImage]) }, [metadataState, userRobotImage, authState.usersPubkey])
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => { const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget) setAnchorElUser(event.currentTarget)
@ -172,13 +177,13 @@ export const AppBar = () => {
onClick={() => { onClick={() => {
setAnchorElUser(null) setAnchorElUser(null)
navigate(appPrivateRoutes.relays) navigate(appPrivateRoutes.settings)
}} }}
sx={{ sx={{
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
Relays Settings
</MenuItem> </MenuItem>
<Link <Link
to={appPublicRoutes.source} to={appPublicRoutes.source}

View File

@ -14,71 +14,130 @@ import { NostrController } from '.'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { queryNip05 } from '../utils' import { queryNip05 } from '../utils'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' 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 nostrController: NostrController
private specialMetadataRelay = 'wss://purplepag.es' private specialMetadataRelay = 'wss://purplepag.es'
constructor() { constructor() {
super()
this.nostrController = NostrController.getInstance() this.nostrController = NostrController.getInstance()
} }
public getEmptyMetadataEvent = (): Event => { /**
return { * Asynchronously checks for more recent metadata events authored by a specific key.
content: '', * If a more recent metadata event is found, it is handled and returned.
created_at: new Date().valueOf(), * If no more recent event is found, the current event is returned.
id: '', * @param hexKey The hexadecimal key of the author to filter metadata events.
kind: 0, * @param currentEvent The current metadata event, if any, to compare with newer events.
pubkey: '', * @returns A promise resolving to the most recent metadata event found, or null if none is found.
sig: '', */
tags: [] private async checkForMoreRecentMetadata(
} hexKey: string,
} currentEvent: Event | null
): Promise<Event | null> {
public findMetadata = async (hexKey: string) => { // Define the event filter to only include metadata events authored by the given key
const eventFilter: Filter = { const eventFilter: Filter = {
kinds: [kinds.Metadata], kinds: [kinds.Metadata], // Only metadata events
authors: [hexKey] authors: [hexKey] // Authored by the specified key
} }
const pool = new SimplePool() const pool = new SimplePool()
// Try to get the metadata event from a special relay (wss://purplepag.es)
const metadataEvent = await pool const metadataEvent = await pool
.get([this.specialMetadataRelay], eventFilter) .get([this.specialMetadataRelay], eventFilter)
.catch((err) => { .catch((err) => {
console.error(err) console.error(err) // Log any errors
return null return null // Return null if an error occurs
}) })
// If a valid metadata event is found from the special relay
if ( if (
metadataEvent && metadataEvent &&
validateEvent(metadataEvent) && validateEvent(metadataEvent) && // Validate the event
verifyEvent(metadataEvent) verifyEvent(metadataEvent) // Verify the event's authenticity
) { ) {
return metadataEvent // 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() const mostPopularRelays = await this.nostrController.getMostPopularRelays()
// Query the most popular relays for metadata events
const events = await pool const events = await pool
.querySync(mostPopularRelays, eventFilter) .querySync(mostPopularRelays, eventFilter)
.catch((err) => { .catch((err) => {
console.error(err) console.error(err) // Log any errors
return null // Return null if an error occurs
return null
}) })
// If events are found from the popular relays
if (events && events.length) { 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) { 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 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) => { public findRelayListMetadata = async (hexKey: string) => {
@ -142,7 +201,7 @@ export class MetadataController {
throw new Error('No relay list metadata found.') throw new Error('No relay list metadata found.')
} }
public extractProfileMetadataContent = (event: VerifiedEvent) => { public extractProfileMetadataContent = (event: Event) => {
try { try {
if (!event.content) return {} if (!event.content) return {}
return JSON.parse(event.content) as ProfileMetadata return JSON.parse(event.content) as ProfileMetadata
@ -175,6 +234,7 @@ export class MetadataController {
.publishEvent(signedMetadataEvent, [this.specialMetadataRelay]) .publishEvent(signedMetadataEvent, [this.specialMetadataRelay])
.then((relays) => { .then((relays) => {
toast.success(`Metadata event published on: ${relays.join('\n')}`) toast.success(`Metadata event published on: ${relays.join('\n')}`)
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
}) })
.catch((err) => { .catch((err) => {
toast.error(err.message) toast.error(err.message)
@ -193,6 +253,8 @@ export class MetadataController {
userRelays.push(...relaySet.write) userRelays.push(...relaySet.write)
} else { } else {
const metadata = await this.findMetadata(hexKey) const metadata = await this.findMetadata(hexKey)
if (!metadata) return null
const metadataContent = this.extractProfileMetadataContent(metadata) const metadataContent = this.extractProfileMetadataContent(metadata)
if (metadataContent?.nip05) { if (metadataContent?.nip05) {
@ -306,4 +368,16 @@ export class MetadataController {
} }
public validate = (event: Event) => validateEvent(event) && verifyEvent(event) public validate = (event: Event) => validateEvent(event) && verifyEvent(event)
public getEmptyMetadataEvent = (): Event => {
return {
content: '',
created_at: new Date().valueOf(),
id: '',
kind: 0,
pubkey: '',
sig: '',
tags: []
}
}
} }

View File

@ -18,8 +18,7 @@ import { MetadataController, NostrController } from '../controllers'
import { LoginMethods } from '../store/auth/types' import { LoginMethods } from '../store/auth/types'
import { setUserRobotImage } from '../store/userRobotImage/action' import { setUserRobotImage } from '../store/userRobotImage/action'
import { State } from '../store/rootReducer' import { State } from '../store/rootReducer'
import { Event, kinds } from 'nostr-tools'
const metadataController = new MetadataController()
export const MainLayout = () => { export const MainLayout = () => {
const dispatch: Dispatch = useDispatch() const dispatch: Dispatch = useDispatch()
@ -27,6 +26,8 @@ export const MainLayout = () => {
const authState = useSelector((state: State) => state.auth) const authState = useSelector((state: State) => state.auth)
useEffect(() => { useEffect(() => {
const metadataController = new MetadataController()
const logout = () => { const logout = () => {
dispatch( dispatch(
setAuthState({ setAuthState({
@ -68,6 +69,20 @@ export const MainLayout = () => {
nostrController.createNsecBunkerSigner(usersPubkey) 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)
})
} }
} }

View File

@ -35,6 +35,7 @@ import {
generateEncryptionKey, generateEncryptionKey,
getHash, getHash,
hexToNpub, hexToNpub,
isOnline,
npubToHex, npubToHex,
queryNip05, queryNip05,
sendDM, sendDM,
@ -49,6 +50,7 @@ import type { Identifier, XYCoord } from 'dnd-core'
import { useDrag, useDrop } from 'react-dnd' import { useDrag, useDrop } from 'react-dnd'
import saveAs from 'file-saver' import saveAs from 'file-saver'
import CopyModal from '../../components/copyModal' import CopyModal from '../../components/copyModal'
import { Event, kinds } from 'nostr-tools'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -337,7 +339,7 @@ export const CreatePage = () => {
const blob = new Blob([encryptedArrayBuffer]) const blob = new Blob([encryptedArrayBuffer])
if (navigator.onLine) { if (await isOnline()) {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Uploading zip file to file storage.') setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController) const fileUrl = await uploadToFileStorage(blob, nostrController)
@ -517,16 +519,26 @@ const DisplayUser = ({
if (!(user.pubkey in metadata)) { if (!(user.pubkey in metadata)) {
const metadataController = new MetadataController() const metadataController = new MetadataController()
const handleMetadataEvent = (event: Event) => {
const metadataContent =
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 metadataController
.findMetadata(user.pubkey) .findMetadata(user.pubkey)
.then((metadataEvent) => { .then((metadataEvent) => {
const metadataContent = if (metadataEvent) handleMetadataEvent(metadataEvent)
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[user.pubkey]: metadataContent
}))
}) })
.catch((err) => { .catch((err) => {
console.error( console.error(

View File

@ -2,7 +2,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material' import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material'
import { truncate } from 'lodash' 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 { useEffect, useMemo, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
@ -75,6 +75,20 @@ export const ProfilePage = () => {
if (pubkey) { if (pubkey) {
const getMetadata = async (pubkey: string) => { 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 const metadataEvent = await metadataController
.findMetadata(pubkey) .findMetadata(pubkey)
.catch((err) => { .catch((err) => {
@ -82,13 +96,7 @@ export const ProfilePage = () => {
return null return null
}) })
if (metadataEvent) { if (metadataEvent) handleMetadataEvent(metadataEvent)
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent) {
setProfileMetadata(metadataContent)
}
}
setIsLoading(false) setIsLoading(false)
} }

View 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
View 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>
</>
)
}

View File

@ -11,7 +11,7 @@ import {
Typography, Typography,
useTheme useTheme
} from '@mui/material' } 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 { 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'
@ -95,6 +95,20 @@ export const ProfileSettingsPage = () => {
if (pubkey) { if (pubkey) {
const getMetadata = async (pubkey: string) => { 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 const metadataEvent = await metadataController
.findMetadata(pubkey) .findMetadata(pubkey)
.catch((err) => { .catch((err) => {
@ -102,13 +116,7 @@ export const ProfileSettingsPage = () => {
return null return null
}) })
if (metadataEvent) { if (metadataEvent) handleMetadataEvent(metadataEvent)
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent) {
setProfileMetadata(metadataContent)
}
}
setIsLoading(false) setIsLoading(false)
} }

View File

@ -1,37 +1,36 @@
import { useEffect, useState } from 'react' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { Box, List, ListItem, TextField } from '@mui/material' import ElectricBoltIcon from '@mui/icons-material/ElectricBolt'
import RouterIcon from '@mui/icons-material/Router' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import styles from './style.module.scss' import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'
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 LogoutIcon from '@mui/icons-material/Logout' import LogoutIcon from '@mui/icons-material/Logout'
import { useAppSelector, useAppDispatch } from '../../hooks' import RouterIcon from '@mui/icons-material/Router'
import { import { Box, List, ListItem, TextField, Tooltip } from '@mui/material'
compareObjects, import Button from '@mui/material/Button'
shorten, import Divider from '@mui/material/Divider'
hexToNpub, import InputAdornment from '@mui/material/InputAdornment'
capitalizeFirstLetter import ListItemText from '@mui/material/ListItemText'
} from '../../utils' 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 { import {
setRelayMapAction, setRelayMapAction,
setRelayMapUpdatedAction setRelayMapUpdatedAction
} from '../../store/actions' } from '../../../store/actions'
import { toast } from 'react-toastify' import {
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' RelayConnectionState,
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' RelayFee,
import ContentCopyIcon from '@mui/icons-material/ContentCopy' RelayInfoObject,
import ElectricBoltIcon from '@mui/icons-material/ElectricBolt' RelayMap
import { Tooltip } from '@mui/material' } from '../../../types'
import InputAdornment from '@mui/material/InputAdornment' import {
import Button from '@mui/material/Button' capitalizeFirstLetter,
compareObjects,
hexToNpub,
shorten
} from '../../../utils'
import styles from './style.module.scss'
export const RelaysPage = () => { export const RelaysPage = () => {
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()

View File

@ -1,4 +1,4 @@
@import '../../colors.scss'; @import '../../../colors.scss';
.container { .container {
margin-top: 25px; margin-top: 25px;

View File

@ -20,7 +20,7 @@ import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import _ from 'lodash' import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input' 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 { useEffect, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
@ -50,7 +50,8 @@ import {
sendDM, sendDM,
shorten, shorten,
signEventForMetaFile, signEventForMetaFile,
uploadToFileStorage uploadToFileStorage,
isOnline
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { import {
@ -433,7 +434,7 @@ export const SignPage = () => {
const blob = new Blob([encryptedArrayBuffer]) const blob = new Blob([encryptedArrayBuffer])
if (navigator.onLine) { if (await isOnline()) {
setLoadingSpinnerDesc('Uploading zip file to file storage.') setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController) const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => { .then((url) => {
@ -846,17 +847,27 @@ const DisplayMeta = ({
hexKeys.forEach((key) => { hexKeys.forEach((key) => {
if (!(key in metadata)) { if (!(key in metadata)) {
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[key]: metadataContent
}))
}
metadataController.on(key, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController metadataController
.findMetadata(key) .findMetadata(key)
.then((metadataEvent) => { .then((metadataEvent) => {
const metadataContent = if (metadataEvent) handleMetadataEvent(metadataEvent)
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[key]: metadataContent
}))
}) })
.catch((err) => { .catch((err) => {
console.error(`error occurred in finding metadata for: ${key}`, err) console.error(`error occurred in finding metadata for: ${key}`, err)
@ -902,7 +913,9 @@ const DisplayMeta = ({
<UserComponent <UserComponent
pubkey={submittedBy} pubkey={submittedBy}
name={ name={
profile?.display_name || profile?.name || shorten(submittedBy) profile?.display_name ||
profile?.name ||
shorten(hexToNpub(submittedBy))
} }
image={profile?.picture} image={profile?.picture}
/> />

View File

@ -10,7 +10,7 @@ import {
} from '@mui/material' } from '@mui/material'
import JSZip from 'jszip' import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' 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 { useEffect, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
@ -107,16 +107,26 @@ export const VerifyPage = () => {
const pubkey = npubToHex(user)! const pubkey = npubToHex(user)!
if (!(pubkey in metadata)) { if (!(pubkey in metadata)) {
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[pubkey]: metadataContent
}))
}
metadataController.on(pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController metadataController
.findMetadata(pubkey) .findMetadata(pubkey)
.then((metadataEvent) => { .then((metadataEvent) => {
const metadataContent = if (metadataEvent) handleMetadataEvent(metadataEvent)
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[pubkey]: metadataContent
}))
}) })
.catch((err) => { .catch((err) => {
console.error( console.error(

View File

@ -1,21 +1,25 @@
import { HomePage } from '../pages/home'
import { CreatePage } from '../pages/create' import { CreatePage } from '../pages/create'
import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing/LandingPage' import { LandingPage } from '../pages/landing/LandingPage'
import { Login } from '../pages/login' import { Login } from '../pages/login'
import { ProfilePage } from '../pages/profile' 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 { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify' import { VerifyPage } from '../pages/verify'
import { ProfileSettingsPage } from '../pages/settings/profile' import { hexToNpub } from '../utils'
import { RelaysPage } from '../pages/relays'
export const appPrivateRoutes = { export const appPrivateRoutes = {
homePage: '/', homePage: '/',
create: '/create', create: '/create',
sign: '/sign', sign: '/sign',
verify: '/verify', verify: '/verify',
settings: '/settings',
profileSettings: '/settings/profile/:npub', profileSettings: '/settings/profile/:npub',
relays: '/relays' cacheSettings: '/settings/cache',
relays: '/settings/relays'
} }
export const appPublicRoutes = { export const appPublicRoutes = {
@ -65,10 +69,18 @@ export const privateRoutes = [
path: appPrivateRoutes.verify, path: appPrivateRoutes.verify,
element: <VerifyPage /> element: <VerifyPage />
}, },
{
path: appPrivateRoutes.settings,
element: <SettingsPage />
},
{ {
path: appPrivateRoutes.profileSettings, path: appPrivateRoutes.profileSettings,
element: <ProfileSettingsPage /> element: <ProfileSettingsPage />
}, },
{
path: appPrivateRoutes.cacheSettings,
element: <CacheSettingsPage />
},
{ {
path: appPrivateRoutes.relays, path: appPrivateRoutes.relays,
element: <RelaysPage /> element: <RelaysPage />

63
src/services/cache/index.ts vendored Normal file
View 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
View 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
View File

@ -0,0 +1 @@
export * from './cache'

6
src/types/cache.ts Normal file
View File

@ -0,0 +1,6 @@
import { Event } from 'nostr-tools'
export interface CachedMetadataEvent {
event: Event
cachedAt: number
}

View File

@ -1,5 +1,6 @@
export * from './cache'
export * from './core' export * from './core'
export * from './nostr' export * from './nostr'
export * from './profile' export * from './profile'
export * from './zip'
export * from './relay' export * from './relay'
export * from './zip'

View File

@ -11,3 +11,41 @@ export const compareObjects = (
return JSON.stringify(obj1) === JSON.stringify(obj2) 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
}
}

View File

@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2022", // Updated to ES2022 for better top-level await support
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"], // Updated to ES2022
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,

View File

@ -1,7 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
// https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tsconfigPaths()],
build: {
target: 'ES2022'
}
}) })