diff --git a/package-lock.json b/package-lock.json index ac416f3..ac56079 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index da3e20a..dc00ccf 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 6d54e77..a44030b 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -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) => { setAnchorElUser(event.currentTarget) @@ -172,13 +177,13 @@ export const AppBar = () => { onClick={() => { setAnchorElUser(null) - navigate(appPrivateRoutes.relays) + navigate(appPrivateRoutes.settings) }} sx={{ justifyContent: 'center' }} > - Relays + Settings { - 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 { + // 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 ) { - 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() + // 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 => { + // 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: [] + } + } } diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 9c4f4e4..c63e74d 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -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) + }) } } diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 4289525..1a3d207 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -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() + 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 .findMetadata(user.pubkey) .then((metadataEvent) => { - const metadataContent = - metadataController.extractProfileMetadataContent(metadataEvent) - if (metadataContent) - setMetadata((prev) => ({ - ...prev, - [user.pubkey]: metadataContent - })) + if (metadataEvent) handleMetadataEvent(metadataEvent) }) .catch((err) => { console.error( diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index bc08206..e3c7fe4 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -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) } diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx new file mode 100644 index 0000000..64c47d0 --- /dev/null +++ b/src/pages/settings/Settings.tsx @@ -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 ( + <> + + + {!disabled && ( + + )} + + ) + } + + return ( + + Settings + + } + > + { + navigate(getProfileSettingsRoute(usersPubkey!)) + }} + > + + + + {listItem('Profile')} + + { + navigate(appPrivateRoutes.relays) + }} + > + + + + {listItem('Relays')} + + { + navigate(appPrivateRoutes.cacheSettings) + }} + > + + + + {listItem('Local Cache')} + + + ) +} diff --git a/src/pages/settings/cache/index.tsx b/src/pages/settings/cache/index.tsx new file mode 100644 index 0000000..8ac1e39 --- /dev/null +++ b/src/pages/settings/cache/index.tsx @@ -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 ( + + ) + } + + return ( + <> + {isLoading && } + + Cache Setting + + } + > + + + + + {listItem('Export (coming soon)')} + + + + + + + {listItem('Import (coming soon)')} + + + + + + + {listItem('Clear Cache')} + + + + ) +} diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 9b68872..bdb8c69 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -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) } diff --git a/src/pages/relays/index.tsx b/src/pages/settings/relays/index.tsx similarity index 97% rename from src/pages/relays/index.tsx rename to src/pages/settings/relays/index.tsx index c5ba03f..194b366 100644 --- a/src/pages/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -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() diff --git a/src/pages/relays/style.module.scss b/src/pages/settings/relays/style.module.scss similarity index 98% rename from src/pages/relays/style.module.scss rename to src/pages/settings/relays/style.module.scss index 25d6347..66e8c33 100644 --- a/src/pages/relays/style.module.scss +++ b/src/pages/settings/relays/style.module.scss @@ -1,4 +1,4 @@ -@import '../../colors.scss'; +@import '../../../colors.scss'; .container { margin-top: 25px; diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 269264f..ccbe2ef 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -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)) { + 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 .findMetadata(key) .then((metadataEvent) => { - const metadataContent = - metadataController.extractProfileMetadataContent(metadataEvent) - - if (metadataContent) - setMetadata((prev) => ({ - ...prev, - [key]: metadataContent - })) + if (metadataEvent) handleMetadataEvent(metadataEvent) }) .catch((err) => { console.error(`error occurred in finding metadata for: ${key}`, err) @@ -902,7 +913,9 @@ const DisplayMeta = ({ diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index e981252..a8e0d1f 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -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)) { + 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 .findMetadata(pubkey) .then((metadataEvent) => { - const metadataContent = - metadataController.extractProfileMetadataContent(metadataEvent) - if (metadataContent) - setMetadata((prev) => ({ - ...prev, - [pubkey]: metadataContent - })) + if (metadataEvent) handleMetadataEvent(metadataEvent) }) .catch((err) => { console.error( diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 5f61b49..b6b9baf 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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: }, + { + path: appPrivateRoutes.settings, + element: + }, { path: appPrivateRoutes.profileSettings, element: }, + { + path: appPrivateRoutes.cacheSettings, + element: + }, { path: appPrivateRoutes.relays, element: diff --git a/src/services/cache/index.ts b/src/services/cache/index.ts new file mode 100644 index 0000000..3fc8de0 --- /dev/null +++ b/src/services/cache/index.ts @@ -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 + + // Private constructor to prevent direct instantiation + private constructor() {} + + // Method to initialize the database + private async init() { + this.db = await openDB('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 { + // 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 { + 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() diff --git a/src/services/cache/schema.ts b/src/services/cache/schema.ts new file mode 100644 index 0000000..869aabf --- /dev/null +++ b/src/services/cache/schema.ts @@ -0,0 +1,9 @@ +import { DBSchema } from 'idb' +import { CachedMetadataEvent } from '../../types' + +export interface SchemaV1 extends DBSchema { + userMetadata: { + key: string + value: CachedMetadataEvent + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..79b5128 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1 @@ +export * from './cache' diff --git a/src/types/cache.ts b/src/types/cache.ts new file mode 100644 index 0000000..2a0edcd --- /dev/null +++ b/src/types/cache.ts @@ -0,0 +1,6 @@ +import { Event } from 'nostr-tools' + +export interface CachedMetadataEvent { + event: Event + cachedAt: number +} diff --git a/src/types/index.ts b/src/types/index.ts index 9397745..6c9f259 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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' diff --git a/src/utils/utils.ts b/src/utils/utils.ts index a436fae..fc5ade3 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -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 + } +} diff --git a/tsconfig.json b/tsconfig.json index 9753f7b..3d03762 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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, diff --git a/vite.config.ts b/vite.config.ts index 5a33944..3d09617 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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' + } })