From e85e9519d2a55775ddda30b3d743158e4db6cb39 Mon Sep 17 00:00:00 2001 From: enes <mulahasanovic@outlook.com> Date: Fri, 11 Oct 2024 15:24:32 +0200 Subject: [PATCH 01/28] feat: add private dm sending --- src/pages/create/index.tsx | 33 ++++++- src/pages/sign/index.tsx | 55 ++++++++++- src/types/errors/SendDMError.ts | 24 +++++ src/utils/nostr.ts | 166 ++++++++++++++++++++++++++++++-- src/utils/relays.ts | 13 ++- 5 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 src/types/errors/SendDMError.ts diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 3374a08..de263e6 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -39,7 +39,9 @@ import { updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, - settleAllFullfilfedPromises + settleAllFullfilfedPromises, + sendPrivateDirectMessage, + parseNostrEvent } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -62,6 +64,7 @@ import { } from '@fortawesome/free-solid-svg-icons' import { getSigitFile, SigitFile } from '../../utils/file.ts' import _ from 'lodash' +import { SendDMError } from '../../types/errors/SendDMError.ts' export const CreatePage = () => { const navigate = useNavigate() @@ -713,6 +716,34 @@ export const CreatePage = () => { toast.error('Failed to publish notifications') }) + setLoadingSpinnerDesc('Sending DMs') + // Send DM to signers and viewers + // No need to send notification to self so remove it from the list + const receivers = ( + signers.length > 0 + ? [signers[0].pubkey] + : viewers.map((viewer) => viewer.pubkey) + ).filter((receiver) => receiver !== usersPubkey) + + for (let i = 0; i < receivers.length; i++) { + const createSignatureEvent = await parseNostrEvent( + meta.createSignature + ) + const { id } = createSignatureEvent + const r = receivers[i] + try { + await sendPrivateDirectMessage( + `Sigit created, visit ${window.location.origin}/#/${signers.length > 0 ? 'sign' : 'verify'}/${id}`, + npubToHex(r!)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + navigate(appPrivateRoutes.sign, { state: { meta } }) } else { const zip = new JSZip() diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index dbbcd98..be64a67 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -33,7 +33,9 @@ import { signEventForMetaFile, updateUsersAppData, findOtherUserMarks, - timeout + timeout, + sendPrivateDirectMessage, + parseNostrEvent } from '../../utils' import { Container } from '../../components/Container' import { DisplayMeta } from './internal/displayMeta' @@ -53,6 +55,7 @@ import { SigitFile } from '../../utils/file.ts' import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' +import { SendDMError } from '../../types/errors/SendDMError.ts' enum SignedStatus { Fully_Signed, @@ -720,6 +723,56 @@ export const SignPage = () => { toast.error('Failed to publish notifications') }) + // Send DMs + setLoadingSpinnerDesc('Sending DMs') + const createSignatureEvent = await parseNostrEvent(meta.createSignature) + const { id } = createSignatureEvent + + if (isLastSigner) { + // Final sign sends to everyone (creator, signers, viewers - /verify) + const areSent: boolean[] = Array(users.length).fill(false) + for (let i = 0; i < users.length; i++) { + try { + areSent[i] = await sendPrivateDirectMessage( + `Sigit completed, visit ${window.location.origin}/#/verify/${id}`, + npubToHex(users[i])! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + } else { + // Notify the creator and the next signer (/sign). + try { + await sendPrivateDirectMessage( + `Sigit signed by ${usersNpub}, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(submittedBy!)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + + try { + const currentSignerIndex = signers.indexOf(usersNpub) + const nextSigner = signers[currentSignerIndex + 1] + await sendPrivateDirectMessage( + `You're the next signer, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(nextSigner)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) + } + } + setIsLoading(false) } diff --git a/src/types/errors/SendDMError.ts b/src/types/errors/SendDMError.ts new file mode 100644 index 0000000..223823b --- /dev/null +++ b/src/types/errors/SendDMError.ts @@ -0,0 +1,24 @@ +import { Jsonable } from '.' + +export enum SendDMErrorType { + 'METADATA_FETCH_FAILED' = 'Sending DM failed. An error occured while fetching user metadata.', + 'RELAY_READ_EMPTY' = `Sending DM failed. The user's relay read set is empty.`, + 'ENCRYPTION_FAILED' = 'Sending DM failed. An error occurred in encrypting dm message.', + 'RELAY_PUBLISH_FAILED' = 'Sending DM failed. Publishing events failed.' +} + +export class SendDMError extends Error { + public readonly context?: Jsonable + + constructor( + message: string, + options: { cause?: Error; context?: Jsonable } = {} + ) { + const { cause, context } = options + + super(message, { cause }) + this.name = this.constructor.name + + this.context = context + } +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ec8c97e..2046b38 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -6,6 +6,7 @@ import { EventTemplate, Filter, UnsignedEvent, + VerifiedEvent, finalizeEvent, generateSecretKey, getEventHash, @@ -21,7 +22,8 @@ import { NIP05_REGEX } from '../constants' import { MetadataController, NostrController, - relayController + relayController, + RelayController } from '../controllers' import { updateProcessedGiftWraps, @@ -35,6 +37,7 @@ import { parseJson, removeLeadingSlash } from './string' import { timeout } from './utils' import { getHash } from './hash' import { SIGIT_BLOSSOM } from './const.ts' +import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError.ts' /** * Generates a `d` tag for userAppData @@ -248,6 +251,12 @@ export const toUnixTimestamp = (date: number | Date) => { export const fromUnixTimestamp = (unix: number) => { return unix * 1000 } +export const randomTimeUpTo2DaysInThePast = (): number => { + const now = Date.now() + const twoDaysInMilliseconds = 2 * 24 * 60 * 60 * 1000 + const randomPastTime = now - Math.floor(Math.random() * twoDaysInMilliseconds) + return toUnixTimestamp(randomPastTime) +} /** * Generate nip44 conversation key @@ -297,19 +306,21 @@ export const countLeadingZeroes = (hex: string) => { /** * Function to create a wrapped event with PoW - * @param event Original event to be wrapped + * @param event Original event to be wrapped (can be unsigned or verified) * @param receiver Public key of the receiver - * @param difficulty PoW difficulty level (default is 20) * @returns */ // -export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { +export const createWrap = ( + event: UnsignedEvent | VerifiedEvent, + receiver: string +) => { // Generate a random secret key and its corresponding public key const randomKey = generateSecretKey() const pubkey = getPublicKey(randomKey) // Encrypt the event content using nip44 encryption - const content = nip44Encrypt(unsignedEvent, randomKey, receiver) + const content = nip44Encrypt(event, randomKey, receiver) // Initialize nonce and leadingZeroes for PoW calculation let nonce = 0 @@ -320,11 +331,12 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { // eslint-disable-next-line no-constant-condition while (true) { // Create an unsigned event with the necessary fields + // TODO: kinds.GiftWrap (wrong kind number in nostr-tools 10/11/2024 at v2.7.2) const event: UnsignedEvent = { kind: 1059, // Event kind content, // Encrypted content pubkey, // Public key of the creator - created_at: unixNow(), // Current timestamp + created_at: randomTimeUpTo2DaysInThePast(), tags: [ // Tags including receiver and nonce ['p', receiver], @@ -989,3 +1001,145 @@ export const getProfileUsername = ( truncate(profile?.display_name || profile?.name || hexToNpub(npub), { length: 16 }) + +/** + * Modified {@link UnsignedEvent Unsigned Event} that includes an id + * + * Fields id and created_at are required. + * @see {@link UnsignedEvent} + * @see {@link https://github.com/nostr-protocol/nips/blob/master/17.md#direct-message-kind} + */ +type UnsignedEventWithId = UnsignedEvent & { + id?: string +} +export const sendPrivateDirectMessage = async ( + message: string, + receiver: string, + subject?: string +) => { + // Instantiate the MetadataController to retrieve relay list metadata to look for preferred DM relays + const metadataController = MetadataController.getInstance() + const relaySet = await metadataController + .findRelayListMetadata(receiver) + .catch((err) => { + // Log an error if retrieving relay list metadata fails + console.log( + `An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`, + err + ) + return null + }) + + // Throw if metadata retrieval failed + if (!relaySet) { + throw new SendDMError(SendDMErrorType.METADATA_FETCH_FAILED, { + context: { + receiver + } + }) + } + + // Ensure relay list is not empty + if (relaySet.read.length === 0) { + throw new SendDMError(SendDMErrorType.RELAY_READ_EMPTY, { + context: { + receiver, + relaySet: JSON.stringify(relaySet) + } + }) + } + // Get the direct message preferred relays list + // TODO: kinds.DirectMessageRelaysList (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + // https://github.com/nostr-protocol/nips/blob/master/17.md#publishing + const eventFilter: Filter = { + kinds: [10050], + authors: [receiver] + } + const preferredRelaysListEvents = + await RelayController.getInstance().fetchEvents(eventFilter, relaySet.read) + + const isRelayTag = (tag: string[]): boolean => tag[0] === 'relay' + const preferredRelaysList = preferredRelaysListEvents.reduce( + (previous: string[], current: Event) => { + const relaysList = current.tags + .filter((t) => isRelayTag(t) && !previous.includes(t[1])) + .map((t) => t[1]) + + return [...previous, ...relaysList] + }, + [] + ) + // Empty preferred relays list + const finalRelaysList: string[] = + preferredRelaysList?.length > 0 ? preferredRelaysList : [...relaySet.write] + + // Generate "sender" + const senderSecret = generateSecretKey() + const senderPubkey = getPublicKey(senderSecret) + + // Prepare tags for the message + const tags: string[][] = [['p', receiver]] + + // Conversation title + if (subject) tags.push(['subject', subject]) + + // Create private DM event containing the message and relevant metadata + // TODO: kinds.PrivateDirectMessage (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + const dm: UnsignedEventWithId = { + pubkey: senderPubkey, + created_at: unixNow(), + kind: 14, + tags, + content: message + } + + // Calculate the hash based on the UnverifiedEvent + dm.id = getEventHash(dm) + + // Encrypt the private dm using the sender secret and the receiver's public key + const encryptedDm = nip44Encrypt(dm, senderSecret, receiver) + if (!encryptedDm) { + throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { + context: { + receiver, + message, + kind: dm.kind + } + }) + } + + // Seal the message + // TODO: kinds.Seal (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + const sealedMessage: UnsignedEvent = { + kind: 13, // seal + pubkey: senderPubkey, + content: encryptedDm, + created_at: randomTimeUpTo2DaysInThePast(), + tags: [] // no tags + } + + // Finalize and sign the sealed event + const finalizedSeal = finalizeEvent(sealedMessage, senderSecret) + + // Encrypt the seal and gift wrap + const finalizedGiftWrap = createWrap(finalizedSeal, receiver) + + // Publish the finalized gift wrap event (the encrypted DM) to the relays + const publishedOnRelays = await relayController.publish( + finalizedGiftWrap, + finalRelaysList + ) + + // Handle cases where publishing to the relays failed + if (publishedOnRelays.length === 0) { + throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { + context: { + receiver, + count: publishedOnRelays.length + } + }) + } + + // Return true indicating that the DM was successfully sent + return true +} diff --git a/src/utils/relays.ts b/src/utils/relays.ts index bfef7aa..c7be280 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -97,20 +97,23 @@ const isOlderThanOneDay = (cachedAt: number) => { } const isRelayTag = (tag: string[]): boolean => tag[0] === 'r' - +const addRelay = (list: string[], relay: string) => { + // Only add if the list doesn't already include the relay + if (!list.includes(relay)) list.push(relay) +} const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => { if (tag.length >= 3) { const marker = tag[2] if (marker === READ_MARKER) { - obj.read.push(tag[1]) + addRelay(obj.read, tag[1]) } else if (marker === WRITE_MARKER) { - obj.write.push(tag[1]) + addRelay(obj.write, tag[1]) } } if (tag.length === 2) { - obj.read.push(tag[1]) - obj.write.push(tag[1]) + addRelay(obj.read, tag[1]) + addRelay(obj.write, tag[1]) } return obj From 3b4bf9aa29b337aa07e0aba9d06e89116482499c Mon Sep 17 00:00:00 2001 From: enes <mulahasanovic@outlook.com> Date: Mon, 14 Oct 2024 11:58:52 +0200 Subject: [PATCH 02/28] fix: only send to next signer on create --- src/pages/create/index.tsx | 39 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index de263e6..40da254 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -716,31 +716,28 @@ export const CreatePage = () => { toast.error('Failed to publish notifications') }) + // Send DM to the next signer setLoadingSpinnerDesc('Sending DMs') - // Send DM to signers and viewers - // No need to send notification to self so remove it from the list - const receivers = ( - signers.length > 0 - ? [signers[0].pubkey] - : viewers.map((viewer) => viewer.pubkey) - ).filter((receiver) => receiver !== usersPubkey) + if (signers.length > 0 && signers[0].pubkey !== usersPubkey) { + // No need to send notification to self so remove it from the list + const nextSigner = signers[0].pubkey - for (let i = 0; i < receivers.length; i++) { - const createSignatureEvent = await parseNostrEvent( - meta.createSignature - ) - const { id } = createSignatureEvent - const r = receivers[i] - try { - await sendPrivateDirectMessage( - `Sigit created, visit ${window.location.origin}/#/${signers.length > 0 ? 'sign' : 'verify'}/${id}`, - npubToHex(r!)! + if (nextSigner) { + const createSignatureEvent = await parseNostrEvent( + meta.createSignature ) - } catch (error) { - if (error instanceof SendDMError) { - toast.error(error.message) + const { id } = createSignatureEvent + try { + await sendPrivateDirectMessage( + `Sigit created, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(nextSigner)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) } - console.error(error) } } From b04f4fb88d139cdd4050d56d592e41b79ae79a72 Mon Sep 17 00:00:00 2001 From: enes <mulahasanovic@outlook.com> Date: Mon, 14 Oct 2024 13:19:44 +0200 Subject: [PATCH 03/28] refactor: show sent dm count, don't sent twice to creator --- src/pages/sign/index.tsx | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index be64a67..ed925e1 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -744,6 +744,12 @@ export const SignPage = () => { console.error(error) } } + + if (areSent.some((r) => r)) { + toast.success( + `DMs sent ${areSent.filter((r) => r).length}/${users.length}` + ) + } } else { // Notify the creator and the next signer (/sign). try { @@ -758,18 +764,21 @@ export const SignPage = () => { console.error(error) } - try { - const currentSignerIndex = signers.indexOf(usersNpub) - const nextSigner = signers[currentSignerIndex + 1] - await sendPrivateDirectMessage( - `You're the next signer, visit ${window.location.origin}/#/sign/${id}`, - npubToHex(nextSigner)! - ) - } catch (error) { - if (error instanceof SendDMError) { - toast.error(error.message) + // No need to notify creator twice, skipping + const currentSignerIndex = signers.indexOf(usersNpub) + const nextSigner = signers[currentSignerIndex + 1] + if (nextSigner !== submittedBy) { + try { + await sendPrivateDirectMessage( + `You're the next signer, visit ${window.location.origin}/#/sign/${id}`, + npubToHex(nextSigner)! + ) + } catch (error) { + if (error instanceof SendDMError) { + toast.error(error.message) + } + console.error(error) } - console.error(error) } } From 664ed9de06c05b9132a6e6da922982b001152bbe Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 24 Jan 2025 14:52:09 +0100 Subject: [PATCH 04/28] refactor(cache): remove unused cache service --- src/pages/settings/cache/index.tsx | 33 ------------ src/services/cache/index.ts | 86 ------------------------------ src/services/cache/schema.ts | 16 ------ src/services/index.ts | 1 - 4 files changed, 136 deletions(-) delete mode 100644 src/services/cache/index.ts delete mode 100644 src/services/cache/schema.ts diff --git a/src/pages/settings/cache/index.tsx b/src/pages/settings/cache/index.tsx index 35cc95e..5dbf8af 100644 --- a/src/pages/settings/cache/index.tsx +++ b/src/pages/settings/cache/index.tsx @@ -1,4 +1,3 @@ -import ClearIcon from '@mui/icons-material/Clear' import InputIcon from '@mui/icons-material/Input' import IosShareIcon from '@mui/icons-material/IosShare' import { @@ -9,36 +8,12 @@ import { ListSubheader, useTheme } from '@mui/material' -import { useState } from 'react' -import { toast } from 'react-toastify' -import { localCache } from '../../../services' -import { LoadingSpinner } from '../../../components/LoadingSpinner' import { Container } from '../../../components/Container' import { Footer } from '../../../components/Footer/Footer' 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 @@ -53,7 +28,6 @@ export const CacheSettingsPage = () => { return ( <> <Container> - {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} <List sx={{ width: '100%', @@ -87,13 +61,6 @@ export const CacheSettingsPage = () => { </ListItemIcon> {listItem('Import (coming soon)')} </ListItemButton> - - <ListItemButton onClick={handleClearData}> - <ListItemIcon> - <ClearIcon sx={{ color: theme.palette.error.main }} /> - </ListItemIcon> - {listItem('Clear Cache')} - </ListItemButton> </List> </Container> <Footer /> diff --git a/src/services/cache/index.ts b/src/services/cache/index.ts deleted file mode 100644 index 957e45b..0000000 --- a/src/services/cache/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { IDBPDatabase, openDB } from 'idb' -import { Event } from 'nostr-tools' -import { CachedEvent } from '../../types' -import { SchemaV2 } from './schema' - -class LocalCache { - // Static property to hold the single instance of LocalCache - private static instance: LocalCache | null = null - private db!: IDBPDatabase<SchemaV2> - - // Private constructor to prevent direct instantiation - private constructor() {} - - // Method to initialize the database - private async init() { - this.db = await openDB<SchemaV2>('sigit-cache', 2, { - upgrade(db, oldVersion) { - if (oldVersion < 1) { - db.createObjectStore('userMetadata', { keyPath: 'event.pubkey' }) - } - - if (oldVersion < 2) { - const v6 = db as unknown as IDBPDatabase<SchemaV2> - - v6.createObjectStore('userRelayListMetadata', { - 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<CachedEvent | 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) - } - - public async addUserRelayListMetadata(event: Event) { - await this.db.put('userRelayListMetadata', { event, cachedAt: Date.now() }) - } - - public async getUserRelayListMetadata( - key: string - ): Promise<CachedEvent | null> { - const data = await this.db.get('userRelayListMetadata', key) - return data || null - } - - public async deleteUserRelayListMetadata(key: string) { - await this.db.delete('userRelayListMetadata', 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 deleted file mode 100644 index bc21956..0000000 --- a/src/services/cache/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DBSchema } from 'idb' -import { CachedEvent } from '../../types' - -export interface SchemaV1 extends DBSchema { - userMetadata: { - key: string - value: CachedEvent - } -} - -export interface SchemaV2 extends SchemaV1 { - userRelayListMetadata: { - key: string - value: CachedEvent - } -} diff --git a/src/services/index.ts b/src/services/index.ts index b8d275a..eb0b67f 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,2 +1 @@ -export * from './cache' export * from './signer' From 4b5955fa9c4c2a5323cda0a1ae1ef93873eb6a93 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 24 Jan 2025 14:52:34 +0100 Subject: [PATCH 05/28] chore(ndk): bump ndk version --- package-lock.json | 196 +++++++++++++++++++--------------------------- package.json | 4 +- 2 files changed, 83 insertions(+), 117 deletions(-) diff --git a/package-lock.json b/package-lock.json index 65fb09a..9d76e2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,8 @@ "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", - "@nostr-dev-kit/ndk": "2.10.0", - "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", + "@nostr-dev-kit/ndk": "2.11.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.9", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", @@ -1668,15 +1668,13 @@ } }, "node_modules/@noble/secp256k1": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.0.0.tgz", - "integrity": "sha512-rUGBd95e2a45rlmFTqQJYEFA4/gdIARFfuTuTqLglz0PZ6AKyzyXsEZZq7UZn8hZsvaBgpCzKKBJizT2cJERXw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ] + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.2.3.tgz", + "integrity": "sha512-l7r5oEQym9Us7EAigzg30/PQAvynhMt2uoYtT3t26eGDVm9Yii5mZ5jWSWmZ/oSIR2Et0xfc6DXrG0bZ787V3w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -1714,19 +1712,19 @@ } }, "node_modules/@nostr-dev-kit/ndk": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.0.tgz", - "integrity": "sha512-TqCAAo6ylORraAXrzRkCGFN2xTMiFbdER8Y8CtUT0HwOpFG/Wn+PBNeDeDmqkl/6LaPdeyXmVwCWj2KcUjIwYA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.11.0.tgz", + "integrity": "sha512-FKIMtcVsVcquzrC+yir9lOXHCIHmQ3IKEVCMohqEB7N96HjP2qrI9s5utbjI3lkavFNF5tXg1Gp9ODEo7XCfLA==", + "license": "MIT", "dependencies": { - "@noble/curves": "^1.4.0", - "@noble/hashes": "^1.3.1", - "@noble/secp256k1": "^2.0.0", - "@scure/base": "^1.1.1", - "debug": "^4.3.4", - "light-bolt11-decoder": "^3.0.0", - "node-fetch": "^3.3.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@noble/secp256k1": "^2.1.0", + "@scure/base": "^1.1.9", + "debug": "^4.3.6", + "light-bolt11-decoder": "^3.2.0", "nostr-tools": "^2.7.1", - "tseep": "^1.1.1", + "tseep": "^1.2.2", "typescript-lru-cache": "^2.0.0", "utf8-buffer": "^1.0.0", "websocket-polyfill": "^0.0.3" @@ -1736,17 +1734,41 @@ } }, "node_modules/@nostr-dev-kit/ndk-cache-dexie": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.1.tgz", - "integrity": "sha512-tUwEy68bd9GL5JVuZIjcpdwuDEBnaXen3WJ64/GRDtbyE1RB01Y6hHC7IQC9bcQ6SC7XBGyPd+2nuTyR7+Mffg==", + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.9.tgz", + "integrity": "sha512-SZ5FjON0QPekiC7oW9Hy3JQxG0Oxxtud9LBa1q/A49JV/Qppv1x37nFHxi0XLxEbDgFTNYbaN27Zjfp2NPem2g==", + "license": "MIT", "dependencies": { - "@nostr-dev-kit/ndk": "2.10.0", - "debug": "^4.3.4", - "dexie": "^4.0.2", + "@nostr-dev-kit/ndk": "2.11.0", + "debug": "^4.3.7", + "dexie": "^4.0.8", "nostr-tools": "^2.4.0", "typescript-lru-cache": "^2.0.0" } }, + "node_modules/@nostr-dev-kit/ndk-cache-dexie/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@nostr-dev-kit/ndk-cache-dexie/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/curves": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", @@ -1772,6 +1794,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nostr-dev-kit/ndk/node_modules/@scure/base": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.4.tgz", + "integrity": "sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": { "version": "2.10.4", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", @@ -1829,6 +1860,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/tseep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz", + "integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==", + "license": "MIT" + }, "node_modules/@pdf-lib/fontkit": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", @@ -3784,14 +3833,6 @@ "type": "^1.0.1" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", @@ -4719,28 +4760,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4871,17 +4890,6 @@ "node": ">= 6" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -5761,9 +5769,10 @@ } }, "node_modules/light-bolt11-decoder": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.0.0.tgz", - "integrity": "sha512-AKvOigD2pmC8ktnn2TIqdJu0K0qk6ukUmTvHwF3JNkm8uWCqt18Ijn33A/a7gaRZ4PghJ59X+8+MXrzLKdBTmQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", + "license": "MIT", "dependencies": { "@scure/base": "1.1.1" } @@ -6339,41 +6348,6 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-gyp-build": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", @@ -8830,14 +8804,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 98bc510..a9b7583 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,8 @@ "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", - "@nostr-dev-kit/ndk": "2.10.0", - "@nostr-dev-kit/ndk-cache-dexie": "2.5.1", + "@nostr-dev-kit/ndk": "2.11.0", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.9", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", From efe3c2c9c77a81a6eb7045945d358e7cbf2ecf78 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 24 Jan 2025 14:53:17 +0100 Subject: [PATCH 06/28] refactor(dm): update private dm to use ndk --- src/hooks/useNDK.ts | 137 +++++++++++++++++++++++++++++- src/pages/create/index.tsx | 4 +- src/pages/sign/index.tsx | 6 +- src/types/errors/SendDMError.ts | 3 +- src/utils/nostr.ts | 143 -------------------------------- 5 files changed, 142 insertions(+), 151 deletions(-) diff --git a/src/hooks/useNDK.ts b/src/hooks/useNDK.ts index ad9e54f..2076a29 100644 --- a/src/hooks/useNDK.ts +++ b/src/hooks/useNDK.ts @@ -12,7 +12,9 @@ import { import _ from 'lodash' import { Event, + finalizeEvent, generateSecretKey, + getEventHash, getPublicKey, kinds, UnsignedEvent @@ -40,17 +42,21 @@ import { getDTagForUserAppData, getUserAppDataFromBlossom, hexToNpub, + nip44Encrypt, parseJson, + randomTimeUpTo2DaysInThePast, SIGIT_RELAY, unixNow, uploadUserAppDataToBlossom } from '../utils' +import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError' export const useNDK = () => { const dispatch = useAppDispatch() const { ndk, fetchEvent, + fetchEventFromUserRelays, fetchEventsFromUserRelays, publish, getNDKRelayList @@ -503,10 +509,139 @@ export const useNDK = () => { [ndk, usersPubkey, getNDKRelayList] ) + /** + * Modified {@link UnsignedEvent Unsigned Event} that includes an id + * + * Fields id and created_at are required. + * @see {@link UnsignedEvent} + * @see {@link https://github.com/nostr-protocol/nips/blob/master/17.md#direct-message-kind} + */ + type UnsignedEventWithId = UnsignedEvent & { + id?: string + } + const sendPrivateDirectMessage = useCallback( + async (message: string, receiver: string, subject?: string) => { + if (!receiver) throw new SendDMError(SendDMErrorType.MISSING_RECIEVER) + + // Get the direct message preferred relays list + // https://github.com/nostr-protocol/nips/blob/master/17.md#publishing + const preferredRelaysListEvent = await fetchEventFromUserRelays( + { + kinds: [NDKKind.DirectMessageReceiveRelayList], + authors: [receiver] + }, + receiver, + UserRelaysType.Read + ) + + const isRelayTag = (tag: string[]): boolean => tag[0] === 'relay' + const finalRelaysList: string[] = [] + if (preferredRelaysListEvent) { + const preferredRelaysList = preferredRelaysListEvent.tags + .filter((t) => isRelayTag(t)) + .map((t) => t[1]) + + finalRelaysList.push(...preferredRelaysList) + } + + if (!finalRelaysList.length) { + // Get receiver's read relay list + const ndkRelayList = await getNDKRelayList(receiver).catch((err) => { + // Log an error if retrieving relay list metadata fails + console.log( + `An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`, + err + ) + return null + }) + if (ndkRelayList?.readRelayUrls) { + finalRelaysList.push(...ndkRelayList.readRelayUrls) + } + } + + if (!finalRelaysList.length) { + finalRelaysList.push(SIGIT_RELAY) + } + + // Generate "sender" + const senderSecret = generateSecretKey() + const senderPubkey = getPublicKey(senderSecret) + + // Prepare tags for the message + const tags: string[][] = [['p', receiver]] + + // Conversation title + if (subject) tags.push(['subject', subject]) + + // Create private DM event containing the message and relevant metadata + // TODO: kinds.PrivateDirectMessage (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + const dm: UnsignedEventWithId = { + pubkey: senderPubkey, + created_at: unixNow(), + kind: 14, + tags, + content: message + } + + // Calculate the hash based on the UnverifiedEvent + dm.id = getEventHash(dm) + + // Encrypt the private dm using the sender secret and the receiver's public key + const encryptedDm = nip44Encrypt(dm, senderSecret, receiver) + if (!encryptedDm) { + throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { + context: { + receiver, + message, + kind: dm.kind + } + }) + } + + // Seal the message + // TODO: kinds.Seal (unavailabe in nostr-tools 10/10/2024 at v2.7.0) + const sealedMessage: UnsignedEvent = { + kind: 13, // seal + pubkey: senderPubkey, + content: encryptedDm, + created_at: randomTimeUpTo2DaysInThePast(), + tags: [] // no tags + } + + // Finalize and sign the sealed event + const finalizedSeal = finalizeEvent(sealedMessage, senderSecret) + + // Encrypt the seal and gift wrap + const finalizedGiftWrap = createWrap(finalizedSeal, receiver) + + const ndkEvent = new NDKEvent(ndk, finalizedGiftWrap) + + // Publish the finalized gift wrap event (the encrypted DM) to the relays + const publishedOnRelays = await ndkEvent.publish( + NDKRelaySet.fromRelayUrls(finalRelaysList, ndk, true) + ) + + // Handle cases where publishing to the relays failed + if (publishedOnRelays.size === 0) { + throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { + context: { + receiver, + count: publishedOnRelays.size + } + }) + } + + // Return true indicating that the DM was successfully sent + return true + }, + [fetchEventFromUserRelays, getNDKRelayList, ndk] + ) + return { getUsersAppData, subscribeForSigits, updateUsersAppData, - sendNotification + sendNotification, + sendPrivateDirectMessage } } diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 686f080..89a09b4 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -45,7 +45,6 @@ import { uploadToFileStorage, DEFAULT_TOOLBOX, settleAllFullfilfedPromises, - sendPrivateDirectMessage, parseNostrEvent, uploadMetaToFileStorage } from '../../utils' @@ -89,7 +88,8 @@ export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() const { findMetadata, fetchEventsFromUserRelays } = useNDKContext() - const { updateUsersAppData, sendNotification } = useNDK() + const { updateUsersAppData, sendNotification, sendPrivateDirectMessage } = + useNDK() const { uploadedFiles } = location.state || {} const [currentFile, setCurrentFile] = useState<File>() diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 309ee1f..e210cb9 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -29,7 +29,6 @@ import { unixNow, updateMarks, uploadMetaToFileStorage, - sendPrivateDirectMessage, parseNostrEvent } from '../../utils' import { CurrentUserMark, Mark } from '../../types/mark.ts' @@ -44,7 +43,8 @@ export const SignPage = () => { const navigate = useNavigate() const location = useLocation() const params = useParams() - const { updateUsersAppData, sendNotification } = useNDK() + const { updateUsersAppData, sendNotification, sendPrivateDirectMessage } = + useNDK() const usersAppData = useAppSelector((state) => state.userAppData) @@ -607,7 +607,7 @@ export const SignPage = () => { // Send DMs setLoadingSpinnerDesc('Sending DMs') - const createSignatureEvent = await parseNostrEvent(meta.createSignature) + const createSignatureEvent = parseNostrEvent(meta.createSignature) const { id } = createSignatureEvent if (isLastSigner) { diff --git a/src/types/errors/SendDMError.ts b/src/types/errors/SendDMError.ts index 223823b..70cc94a 100644 --- a/src/types/errors/SendDMError.ts +++ b/src/types/errors/SendDMError.ts @@ -1,8 +1,7 @@ import { Jsonable } from '.' export enum SendDMErrorType { - 'METADATA_FETCH_FAILED' = 'Sending DM failed. An error occured while fetching user metadata.', - 'RELAY_READ_EMPTY' = `Sending DM failed. The user's relay read set is empty.`, + 'MISSING_RECIEVER' = 'Sending DM failed. Reciever is required.', 'ENCRYPTION_FAILED' = 'Sending DM failed. An error occurred in encrypting dm message.', 'RELAY_PUBLISH_FAILED' = 'Sending DM failed. Publishing events failed.' } diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 17030ad..3d8aa15 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -23,7 +23,6 @@ import { Meta, SignedEvent } from '../types' import { SIGIT_BLOSSOM } from './const.ts' import { getHash } from './hash' import { parseJson, removeLeadingSlash } from './string' -import { SendDMError, SendDMErrorType } from '../types/errors/SendDMError.ts' /** * Generates a `d` tag for userAppData @@ -514,148 +513,6 @@ export const getProfileUsername = ( length: 16 }) -/** - * Modified {@link UnsignedEvent Unsigned Event} that includes an id - * - * Fields id and created_at are required. - * @see {@link UnsignedEvent} - * @see {@link https://github.com/nostr-protocol/nips/blob/master/17.md#direct-message-kind} - */ -type UnsignedEventWithId = UnsignedEvent & { - id?: string -} -export const sendPrivateDirectMessage = async ( - message: string, - receiver: string, - subject?: string -) => { - // Instantiate the MetadataController to retrieve relay list metadata to look for preferred DM relays - const metadataController = MetadataController.getInstance() - const relaySet = await metadataController - .findRelayListMetadata(receiver) - .catch((err) => { - // Log an error if retrieving relay list metadata fails - console.log( - `An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`, - err - ) - return null - }) - - // Throw if metadata retrieval failed - if (!relaySet) { - throw new SendDMError(SendDMErrorType.METADATA_FETCH_FAILED, { - context: { - receiver - } - }) - } - - // Ensure relay list is not empty - if (relaySet.read.length === 0) { - throw new SendDMError(SendDMErrorType.RELAY_READ_EMPTY, { - context: { - receiver, - relaySet: JSON.stringify(relaySet) - } - }) - } - // Get the direct message preferred relays list - // TODO: kinds.DirectMessageRelaysList (unavailabe in nostr-tools 10/10/2024 at v2.7.0) - // https://github.com/nostr-protocol/nips/blob/master/17.md#publishing - const eventFilter: Filter = { - kinds: [10050], - authors: [receiver] - } - const preferredRelaysListEvents = - await RelayController.getInstance().fetchEvents(eventFilter, relaySet.read) - - const isRelayTag = (tag: string[]): boolean => tag[0] === 'relay' - const preferredRelaysList = preferredRelaysListEvents.reduce( - (previous: string[], current: Event) => { - const relaysList = current.tags - .filter((t) => isRelayTag(t) && !previous.includes(t[1])) - .map((t) => t[1]) - - return [...previous, ...relaysList] - }, - [] - ) - // Empty preferred relays list - const finalRelaysList: string[] = - preferredRelaysList?.length > 0 ? preferredRelaysList : [...relaySet.write] - - // Generate "sender" - const senderSecret = generateSecretKey() - const senderPubkey = getPublicKey(senderSecret) - - // Prepare tags for the message - const tags: string[][] = [['p', receiver]] - - // Conversation title - if (subject) tags.push(['subject', subject]) - - // Create private DM event containing the message and relevant metadata - // TODO: kinds.PrivateDirectMessage (unavailabe in nostr-tools 10/10/2024 at v2.7.0) - const dm: UnsignedEventWithId = { - pubkey: senderPubkey, - created_at: unixNow(), - kind: 14, - tags, - content: message - } - - // Calculate the hash based on the UnverifiedEvent - dm.id = getEventHash(dm) - - // Encrypt the private dm using the sender secret and the receiver's public key - const encryptedDm = nip44Encrypt(dm, senderSecret, receiver) - if (!encryptedDm) { - throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { - context: { - receiver, - message, - kind: dm.kind - } - }) - } - - // Seal the message - // TODO: kinds.Seal (unavailabe in nostr-tools 10/10/2024 at v2.7.0) - const sealedMessage: UnsignedEvent = { - kind: 13, // seal - pubkey: senderPubkey, - content: encryptedDm, - created_at: randomTimeUpTo2DaysInThePast(), - tags: [] // no tags - } - - // Finalize and sign the sealed event - const finalizedSeal = finalizeEvent(sealedMessage, senderSecret) - - // Encrypt the seal and gift wrap - const finalizedGiftWrap = createWrap(finalizedSeal, receiver) - - // Publish the finalized gift wrap event (the encrypted DM) to the relays - const publishedOnRelays = await relayController.publish( - finalizedGiftWrap, - finalRelaysList - ) - - // Handle cases where publishing to the relays failed - if (publishedOnRelays.length === 0) { - throw new SendDMError(SendDMErrorType.ENCRYPTION_FAILED, { - context: { - receiver, - count: publishedOnRelays.length - } - }) - } - - // Return true indicating that the DM was successfully sent - return true -} - /** * Orders an array of NDKEvent objects chronologically based on their `created_at` property. * From 37baf5709397e7099a735d6390cfceffff646f11 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 31 Jan 2025 19:32:28 +0100 Subject: [PATCH 07/28] fix(callback): login and private route redirect Fix #229 --- src/App.tsx | 20 +++------------ src/layouts/Main.tsx | 49 +++++++++++++++++-------------------- src/pages/landing/index.tsx | 18 +++++++------- src/routes/PrivateRoute.tsx | 21 ++++++++++++++++ src/routes/util.tsx | 49 +++++++++++++++++++++++++++++++------ src/utils/localStorage.ts | 24 ------------------ 6 files changed, 97 insertions(+), 84 deletions(-) create mode 100644 src/routes/PrivateRoute.tsx diff --git a/src/App.tsx b/src/App.tsx index 3829ba6..d10dc0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useAppSelector, useAuth } from './hooks' import { MainLayout } from './layouts/Main' - -import { appPrivateRoutes, appPublicRoutes } from './routes' import { privateRoutes, publicRoutes, @@ -16,7 +14,7 @@ import './App.scss' const App = () => { const { checkSession } = useAuth() - const authState = useAppSelector((state) => state.auth) + const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn) useEffect(() => { if (window.location.hostname === '0.0.0.0') { @@ -29,19 +27,9 @@ const App = () => { checkSession() }, [checkSession]) - const handleRootRedirect = () => { - if (authState.loggedIn) return appPrivateRoutes.homePage - - const callbackPathEncoded = btoa( - window.location.href.split(`${window.location.origin}/#`)[1] - ) - - return `${appPublicRoutes.landingPage}?callbackPath=${callbackPathEncoded}` - } - // Hide route only if loggedIn and r.hiddenWhenLoggedIn are both true const publicRoutesList = recursiveRouteRenderer(publicRoutes, (r) => { - return !authState.loggedIn || !r.hiddenWhenLoggedIn + return !isLoggedIn || !r.hiddenWhenLoggedIn }) const privateRouteList = recursiveRouteRenderer(privateRoutes) @@ -49,9 +37,9 @@ const App = () => { return ( <Routes> <Route element={<MainLayout />}> - {authState?.loggedIn && privateRouteList} {publicRoutesList} - <Route path="*" element={<Navigate to={handleRootRedirect()} />} /> + {privateRouteList} + <Route path="*" element={<Navigate to={'/'} />} /> </Route> </Routes> ) diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 85daf75..c91c986 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,16 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Outlet, useNavigate, useSearchParams } from 'react-router-dom' - import { getPublicKey, nip19 } from 'nostr-tools' - import { init as initNostrLogin } from 'nostr-login' import { NostrLoginAuthOptions } from 'nostr-login/dist/types' - import { AppBar } from '../components/AppBar/AppBar' import { LoadingSpinner } from '../components/LoadingSpinner' - import { NostrController } from '../controllers' - import { useAppDispatch, useAppSelector, @@ -19,7 +14,6 @@ import { useNDK, useNDKContext } from '../hooks' - import { restoreState, setUserProfile, @@ -30,9 +24,7 @@ import { setUserRobotImage } from '../store/actions' import { LoginMethod } from '../store/auth/types' - import { getRoboHashPicture, loadState } from '../utils' - import styles from './style.module.scss' export const MainLayout = () => { @@ -53,29 +45,32 @@ export const MainLayout = () => { // Ref to track if `subscribeForSigits` has been called const hasSubscribed = useRef(false) - const navigateAfterLogin = (path: string) => { - const callbackPath = searchParams.get('callbackPath') - - if (callbackPath) { - // base64 decoded path - const path = atob(callbackPath) + const navigateAfterLogin = useCallback( + (path: string) => { + const isCallback = window.location.hash.startsWith('#/?callbackPath=') + if (isCallback) { + const path = atob(window.location.hash.replace('#/?callbackPath=', '')) + setSearchParams((prev) => { + prev.delete('callbackPath') + return prev + }) + navigate(path) + return + } navigate(path) - return - } - - navigate(path) - } + }, + [navigate, setSearchParams] + ) const login = useCallback(async () => { - dispatch(updateLoginMethod(LoginMethod.nostrLogin)) - - const nostrController = NostrController.getInstance() - const pubkey = await nostrController.capturePublicKey() - - const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey) - - if (redirectPath) { + try { + dispatch(updateLoginMethod(LoginMethod.nostrLogin)) + const nostrController = NostrController.getInstance() + const pubkey = await nostrController.capturePublicKey() + const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey) navigateAfterLogin(redirectPath) + } catch (error) { + console.error(`Error occured during login`, error) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch]) diff --git a/src/pages/landing/index.tsx b/src/pages/landing/index.tsx index 773b923..5d24bdf 100644 --- a/src/pages/landing/index.tsx +++ b/src/pages/landing/index.tsx @@ -1,7 +1,5 @@ import { Box, Button } from '@mui/material' -import { useEffect } from 'react' -import { Outlet, useLocation } from 'react-router-dom' -import { saveVisitedLink } from '../../utils' +import { Outlet } from 'react-router-dom' import { CardComponent } from '../../components/Landing/CardComponent/CardComponent' import { Container } from '../../components/Container' import styles from './style.module.scss' @@ -20,13 +18,19 @@ import { import { FontAwesomeIconStack } from '../../components/FontAwesomeIconStack' import { Footer } from '../../components/Footer/Footer' import { launch as launchNostrLoginDialog } from 'nostr-login' +import { useDidMount } from '../../hooks' export const LandingPage = () => { - const location = useLocation() - const onSignInClick = async () => { launchNostrLoginDialog() } + useDidMount(() => { + const isCallback = window.location.hash.startsWith('#/?callbackPath=') + // Open nostr login if detect callback + if (isCallback) { + onSignInClick() + } + }) const cards = [ { @@ -101,10 +105,6 @@ export const LandingPage = () => { } ] - useEffect(() => { - saveVisitedLink(location.pathname, location.search) - }, [location]) - return ( <div className={styles.background}> <div diff --git a/src/routes/PrivateRoute.tsx b/src/routes/PrivateRoute.tsx new file mode 100644 index 0000000..410ecea --- /dev/null +++ b/src/routes/PrivateRoute.tsx @@ -0,0 +1,21 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAppSelector } from '../hooks' +import { appPublicRoutes } from '.' + +export function PrivateRoute({ children }: { children: JSX.Element }) { + const location = useLocation() + const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn) + if (!isLoggedIn) { + return ( + <Navigate + to={{ + pathname: appPublicRoutes.landingPage, + search: `?callbackPath=${btoa(location.pathname)}` + }} + replace + /> + ) + } + + return children +} diff --git a/src/routes/util.tsx b/src/routes/util.tsx index 8773b81..2e1be26 100644 --- a/src/routes/util.tsx +++ b/src/routes/util.tsx @@ -11,6 +11,7 @@ import { RelaysPage } from '../pages/settings/relays' import { SettingsPage } from '../pages/settings/Settings' import { SignPage } from '../pages/sign' import { VerifyPage } from '../pages/verify' +import { PrivateRoute } from './PrivateRoute' /** * Helper type allows for extending react-router-dom's **RouteProps** with generic type @@ -70,34 +71,66 @@ export const publicRoutes: PublicRouteProps[] = [ export const privateRoutes = [ { path: appPrivateRoutes.homePage, - element: <HomePage /> + element: ( + <PrivateRoute> + <HomePage /> + </PrivateRoute> + ) }, { path: appPrivateRoutes.create, - element: <CreatePage /> + element: ( + <PrivateRoute> + <CreatePage /> + </PrivateRoute> + ) }, { path: `${appPrivateRoutes.sign}/:id?`, - element: <SignPage /> + element: ( + <PrivateRoute> + <SignPage /> + </PrivateRoute> + ) }, { path: appPrivateRoutes.settings, - element: <SettingsPage /> + element: ( + <PrivateRoute> + <SettingsPage /> + </PrivateRoute> + ) }, { path: appPrivateRoutes.profileSettings, - element: <ProfileSettingsPage /> + element: ( + <PrivateRoute> + <ProfileSettingsPage /> + </PrivateRoute> + ) }, { path: appPrivateRoutes.cacheSettings, - element: <CacheSettingsPage /> + element: ( + <PrivateRoute> + <CacheSettingsPage /> + </PrivateRoute> + ) }, { path: appPrivateRoutes.relays, - element: <RelaysPage /> + element: ( + <PrivateRoute> + <RelaysPage /> + </PrivateRoute> + ) }, { path: appPrivateRoutes.nostrLogin, - element: <NostrLoginPage /> + element: ( + <PrivateRoute> + <NostrLoginPage /> + </PrivateRoute> + ) } ] diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index 472e092..8196e35 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -26,30 +26,6 @@ export const clearState = () => { localStorage.removeItem('state') } -export const saveVisitedLink = (pathname: string, search: string) => { - localStorage.setItem( - 'visitedLink', - JSON.stringify({ - pathname, - search - }) - ) -} - -export const getVisitedLink = () => { - const visitedLink = localStorage.getItem('visitedLink') - if (!visitedLink) return null - - try { - return JSON.parse(visitedLink) as { - pathname: string - search: string - } - } catch { - return null - } -} - export const saveAuthToken = (token: string) => { localStorage.setItem('authToken', token) } From e405b735f7ca03e6c1fabe8ddaf4ba3e3c6cec6d Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 7 Feb 2025 14:37:08 +0100 Subject: [PATCH 08/28] fix(dm): always add sigit relay when sending private DMs --- src/hooks/useNDK.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useNDK.ts b/src/hooks/useNDK.ts index 2076a29..c6ec41d 100644 --- a/src/hooks/useNDK.ts +++ b/src/hooks/useNDK.ts @@ -559,7 +559,7 @@ export const useNDK = () => { } } - if (!finalRelaysList.length) { + if (!finalRelaysList.includes(SIGIT_RELAY)) { finalRelaysList.push(SIGIT_RELAY) } From 1474fafde7b76f465d895ef0457960c0704ec444 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 7 Feb 2025 14:49:25 +0100 Subject: [PATCH 09/28] fix(dm): don't send private DM twice to same signer --- src/pages/sign/index.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index e210cb9..a1a3039 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -633,7 +633,8 @@ export const SignPage = () => { ) } } else { - // Notify the creator and the next signer (/sign). + // Notify the creator and + // the next signer (/sign). try { await sendPrivateDirectMessage( `Sigit signed by ${usersNpub}, visit ${window.location.origin}/#/sign/${id}`, @@ -648,12 +649,12 @@ export const SignPage = () => { // No need to notify creator twice, skipping const currentSignerIndex = signers.indexOf(usersNpub) - const nextSigner = signers[currentSignerIndex + 1] + const nextSigner = npubToHex(signers[currentSignerIndex + 1]) if (nextSigner !== submittedBy) { try { await sendPrivateDirectMessage( `You're the next signer, visit ${window.location.origin}/#/sign/${id}`, - npubToHex(nextSigner)! + nextSigner! ) } catch (error) { if (error instanceof SendDMError) { From 6f4b41d84b7968286bd3aa7503afe23375e58fdf Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 7 Feb 2025 17:16:45 +0100 Subject: [PATCH 10/28] fix(login): remove default login redirect --- src/hooks/useAuth.ts | 5 ++--- src/layouts/Main.tsx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index e0e75fb..7199a51 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -56,8 +56,7 @@ export const useAuth = () => { * method will be chosen (extension or keys) * * @param pubkey of the user trying to login - * @returns url to redirect if authentication successfull - * or error if otherwise + * @returns url to redirect if user has no relays set */ const authAndGetMetadataAndRelaysMap = useCallback( async (pubkey: string) => { @@ -108,7 +107,7 @@ export const useAuth = () => { dispatch(setRelayMapAction(relayMap)) } - return appPrivateRoutes.homePage + return }, [ dispatch, diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index c91c986..2106931 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -46,7 +46,7 @@ export const MainLayout = () => { const hasSubscribed = useRef(false) const navigateAfterLogin = useCallback( - (path: string) => { + (path: string | undefined) => { const isCallback = window.location.hash.startsWith('#/?callbackPath=') if (isCallback) { const path = atob(window.location.hash.replace('#/?callbackPath=', '')) @@ -57,7 +57,7 @@ export const MainLayout = () => { navigate(path) return } - navigate(path) + if (path) navigate(path) }, [navigate, setSearchParams] ) From f7d0718b7820ebc66310f572b0ceb6e91f5393bd Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 7 Feb 2025 19:14:25 +0100 Subject: [PATCH 11/28] fix(search): tim input, add timeout Fixes #308 --- src/pages/create/index.tsx | 60 +++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index d5424a9..2d476ae 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -45,7 +45,8 @@ import { uploadToFileStorage, DEFAULT_TOOLBOX, settleAllFullfilfedPromises, - uploadMetaToFileStorage + uploadMetaToFileStorage, + timeout } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -79,6 +80,7 @@ import { useNDKContext } from '../../hooks/useNDKContext.ts' import { useNDK } from '../../hooks/useNDK.ts' import { useImmer } from 'use-immer' import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx' +import { TimeoutError } from '../../types/errors/TimeoutError.ts' type FoundUser = NostrEvent & { npub: string } @@ -162,8 +164,8 @@ export const CreatePage = () => { return pubkey } - const handleSearchUsers = async (searchValue?: string) => { - const searchString = searchValue || userSearchInput || undefined + const handleSearchUsers = async () => { + const searchString = userSearchInput || undefined if (!searchString) return @@ -171,14 +173,17 @@ export const CreatePage = () => { const searchTerm = searchString.trim() - fetchEventsFromUserRelays( - { - kinds: [0], - search: searchTerm - }, - usersPubkey, - UserRelaysType.Write - ) + Promise.race([ + fetchEventsFromUserRelays( + { + kinds: [0], + search: searchTerm + }, + usersPubkey, + UserRelaysType.Write + ), + timeout(30000) + ]) .then((events) => { const nostrEvents = events.map((event) => event.rawEvent()) @@ -216,6 +221,9 @@ export const CreatePage = () => { toast.info('No user found with the provided search term') }) .catch((error) => { + if (error instanceof TimeoutError) { + toast.error('Search timed out. Please try again.') + } console.error(error) }) .finally(() => { @@ -245,22 +253,23 @@ export const CreatePage = () => { // If pasted user npub of nip05 is present, we just add the user to the counterparts list if (pastedUserNpubOrNip05) { - setUserInput(pastedUserNpubOrNip05) + setUserInput(pastedUserNpubOrNip05.trim()) setPastedUserNpubOrNip05(undefined) } else { - // Otherwize if search already provided some results, user must manually click the search button + // Otherwise if search already provided some results, user must manually click the search button if (!foundUsers.length) { + const searchTerm = userSearchInput.trim() // If it's NIP05 (includes @ or is a valid domain) send request to .well-known const domainRegex = /^[a-zA-Z0-9@.-]+\.[a-zA-Z]{2,}$/ - if (domainRegex.test(userSearchInput)) { + if (searchTerm.startsWith('_@') || domainRegex.test(searchTerm)) { setSearchUsersLoading(true) - const pubkey = await handleSearchUserNip05(userSearchInput) + const pubkey = await handleSearchUserNip05(searchTerm) setSearchUsersLoading(false) if (pubkey) { - setUserInput(userSearchInput) + setUserInput(searchTerm) } else { toast.error(`No user found with the NIP05: ${userSearchInput}`) } @@ -411,7 +420,7 @@ export const CreatePage = () => { setUserSearchInput('') - if (input.startsWith('npub')) { + if (input.startsWith('npub1')) { return handleAddNpubUser(input) } @@ -1034,17 +1043,8 @@ export const CreatePage = () => { } // Seems like it's npub format - if (value.startsWith('npub')) { - // We will try to convert npub to hex and if it's successfull that means - // npub is valid - const validHexPubkey = npubToHex(value) - - if (validHexPubkey) { - // Arm the manual user npub add after enter is hit, we don't want to trigger search - setPastedUserNpubOrNip05(value) - } else { - disarmAddOnEnter() - } + if (value.trim().startsWith('npub1')) { + setPastedUserNpubOrNip05(value.trim()) } else { // Disarm the add user on enter hit, and trigger search after 1 second disarmAddOnEnter() @@ -1204,7 +1204,7 @@ export const CreatePage = () => { {!pastedUserNpubOrNip05 ? ( <Button disabled={!userSearchInput || searchUsersLoading} - onClick={() => handleSearchUsers()} + onClick={handleSearchUsers} variant="contained" aria-label="Add" className={styles.counterpartToggleButton} @@ -1218,7 +1218,7 @@ export const CreatePage = () => { ) : ( <Button onClick={() => { - setUserInput(userSearchInput) + setUserInput(userSearchInput.trim()) }} variant="contained" aria-label="Add" From 13b88516cac858dd481ba4974509de5b35dffdb1 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Mon, 17 Feb 2025 19:06:19 +0100 Subject: [PATCH 12/28] feat(draft): serialize sigit and save/load to local storage --- src/types/draft.ts | 19 ++++++++ src/types/index.ts | 2 + src/utils/draft.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++ src/utils/index.ts | 1 + 4 files changed, 134 insertions(+) create mode 100644 src/types/draft.ts create mode 100644 src/utils/draft.ts diff --git a/src/types/draft.ts b/src/types/draft.ts new file mode 100644 index 0000000..900d87e --- /dev/null +++ b/src/types/draft.ts @@ -0,0 +1,19 @@ +import { SigitFile } from '../utils/file' +import { User } from './core' +import { DrawnField } from './drawing' + +export interface SigitFileDraft { + name: string + file: string + pages: DrawnField[][] +} +export interface SigitDraft { + title: string + users: User[] + files: SigitFile[] +} +export interface SerializedSigitDraft { + title: string + users: User[] + files: SigitFileDraft[] +} diff --git a/src/types/index.ts b/src/types/index.ts index 5c5b715..40b240b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,5 @@ export * from './nostr' export * from './relay' export * from './zip' export * from './event' +export * from './drawing' +export * from './draft' diff --git a/src/utils/draft.ts b/src/utils/draft.ts new file mode 100644 index 0000000..4e9ae56 --- /dev/null +++ b/src/utils/draft.ts @@ -0,0 +1,112 @@ +import { + DrawnField, + SerializedSigitDraft, + SigitDraft, + SigitFileDraft +} from '../types' +import { + getMediaType, + extractFileExtension, + toFile, + getSigitFile +} from './file' + +let saveSigitDraftTimeout: number | null = null +const serializeSigitDraft = async ( + draft: SigitDraft +): Promise<SerializedSigitDraft> => { + const serializedFiles = draft.files.map((file) => { + return new Promise<SigitFileDraft>((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const pages = file.pages + ? file.pages.map((page) => + page.drawnFields.map( + (field) => + ({ + left: field.left, + top: field.top, + width: field.width, + height: field.height, + type: field.type, + counterpart: field.counterpart + }) as DrawnField + ) + ) + : [] + resolve({ + name: file.name, + pages: pages, + file: reader.result as string + }) + } + reader.onerror = (error) => reject(error) + reader.readAsDataURL(file) + }) + }) + + const serializedFileDraft = await Promise.all(serializedFiles) + return { + title: draft.title, + users: [...draft.users], + files: serializedFileDraft + } +} + +const deserializeSigitDraft = async ( + serializedDraft: SerializedSigitDraft +): Promise<SigitDraft> => { + const files = await Promise.all( + serializedDraft.files.map(async (draft) => { + const response = await fetch(draft.file) + const arrayBuffer = await response.arrayBuffer() + const type = getMediaType(extractFileExtension(draft.name)) + const file = toFile(arrayBuffer, draft.name, type) + const sigitFile = await getSigitFile(file) + if (draft.pages) { + for (let i = 0; i < draft.pages.length; i++) { + const drawnFields = draft.pages[i] + if (sigitFile.pages) sigitFile.pages[i].drawnFields = [...drawnFields] + } + } + return sigitFile + }) + ) + + return { + ...serializedDraft, + files: files + } +} + +export const saveSigitDraft = (draft: SigitDraft) => { + if (saveSigitDraftTimeout) { + clearTimeout(saveSigitDraftTimeout) + } + + saveSigitDraftTimeout = window.setTimeout(() => { + serializeSigitDraft(draft) + .then((draftToSave) => { + localStorage.setItem('sigitDraft', JSON.stringify(draftToSave)) + }) + .catch((error) => { + console.log(`Error while saving sigit draft. Error: `, error) + }) + }, 1000) +} + +export const getSigitDraft = async () => { + const sigitDraft = localStorage.getItem('sigitDraft') + if (!sigitDraft) return null + + try { + const serializedDraft = JSON.parse(sigitDraft) as SerializedSigitDraft + return await deserializeSigitDraft(serializedDraft) + } catch { + return null + } +} + +export const clearSigitDraft = () => { + localStorage.removeItem('sigitDraft') +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 274ceab..9c4464a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -12,3 +12,4 @@ export * from './string' export * from './url' export * from './utils' export * from './zip' +export * from './draft' From 9f4a891d5002532b72e9e068a85bd809491aeeef Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Tue, 18 Feb 2025 16:56:40 +0100 Subject: [PATCH 13/28] feat(create): add local draft, save progress to local storage Closes #175 --- package-lock.json | 17 +-- package.json | 3 +- .../DisplaySigit/LocalDraftSigit.tsx | 117 ++++++++++++++++++ src/components/DrawPDFFields/index.tsx | 33 +++-- src/pages/create/index.tsx | 79 ++++++++++-- src/pages/home/index.tsx | 25 +++- src/types/draft.ts | 2 + src/utils/draft.ts | 34 +++-- src/utils/meta.ts | 3 +- 9 files changed, 254 insertions(+), 59 deletions(-) create mode 100644 src/components/DisplaySigit/LocalDraftSigit.tsx diff --git a/package-lock.json b/package-lock.json index 6235019..9e327f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sigit", - "version": "0.0.0-beta", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sigit", - "version": "0.0.0-beta", + "version": "1.0.3", "hasInstallScript": true, "license": "AGPL-3.0-or-later ", "dependencies": { @@ -51,8 +51,7 @@ "react-toastify": "10.0.4", "redux": "5.0.1", "signature_pad": "^5.0.4", - "tseep": "1.2.1", - "use-immer": "^0.11.0" + "tseep": "1.2.1" }, "devDependencies": { "@saithodev/semantic-release-gitea": "^2.1.0", @@ -17265,16 +17264,6 @@ "dev": true, "license": "MIT" }, - "node_modules/use-immer": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.11.0.tgz", - "integrity": "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==", - "license": "MIT", - "peerDependencies": { - "immer": ">=8.0.0", - "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/package.json b/package.json index 3081a75..d650959 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,7 @@ "react-toastify": "10.0.4", "redux": "5.0.1", "signature_pad": "^5.0.4", - "tseep": "1.2.1", - "use-immer": "^0.11.0" + "tseep": "1.2.1" }, "devDependencies": { "@saithodev/semantic-release-gitea": "^2.1.0", diff --git a/src/components/DisplaySigit/LocalDraftSigit.tsx b/src/components/DisplaySigit/LocalDraftSigit.tsx new file mode 100644 index 0000000..f59da16 --- /dev/null +++ b/src/components/DisplaySigit/LocalDraftSigit.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { Tooltip, Button, Divider } from '@mui/material' +import { + faCalendar, + faFile, + faFileCircleExclamation, + faPen, + faTrash +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { SigitDraft, UserRole } from '../../types' +import { appPrivateRoutes } from '../../routes' +import { + formatTimestamp, + getSigitDraft, + npubToHex, + SigitStatus, + SignStatus +} from '../../utils' +import { DisplaySigner } from '../DisplaySigner' +import { UserAvatarGroup } from '../UserAvatarGroup' +import { getExtensionIconLabel } from '../getExtensionIconLabel' +import { useAppSelector, useDidMount } from '../../hooks' +import styles from './style.module.scss' + +interface LocalDraftSigitProps { + handleDraftDelete: () => void +} +export const LocalDraftSigit = ({ + handleDraftDelete +}: LocalDraftSigitProps) => { + const [draft, setDraft] = useState<SigitDraft>() + useDidMount(async () => { + // Check if draft exists and add link to direct + const draft = await getSigitDraft() + if (draft) { + setDraft(draft) + } + }) + const submittedBy = useAppSelector((state) => state.auth.usersPubkey) + + if (!draft) return null + + const extensions = draft.files.map((f) => f.extension) + const isSame = extensions.every((e) => extensions[0] === e) + + return ( + <div className={styles.itemWrapper}> + <Link className={styles.insetLink} to={appPrivateRoutes.create}></Link> + <p className={`line-clamp-2 ${styles.title}`}>{draft.title}</p> + <div className={styles.users}> + {submittedBy && ( + <DisplaySigner status={SignStatus.Pending} pubkey={submittedBy} /> + )} + {submittedBy && draft.users.length ? ( + <Divider orientation="vertical" flexItem /> + ) : null} + <UserAvatarGroup max={7}> + {draft.users.map((user) => { + const pubkey = npubToHex(user.pubkey)! + return ( + <DisplaySigner + key={pubkey} + status={ + user.role === UserRole.signer + ? SignStatus.Pending + : SignStatus.Viewer + } + pubkey={pubkey} + /> + ) + })} + </UserAvatarGroup> + </div> + <div className={`${styles.details} ${styles.iconLabel}`}> + <FontAwesomeIcon icon={faCalendar} /> + {formatTimestamp(draft.lastUpdated)} + </div> + <div className={`${styles.details} ${styles.status}`}> + <span className={styles.iconLabel}> + <FontAwesomeIcon icon={faPen} /> {SigitStatus.LocalDraft} + </span> + {extensions.length > 0 ? ( + <span className={styles.iconLabel}> + {!isSame ? ( + <> + <FontAwesomeIcon icon={faFile} /> Multiple File Types + </> + ) : ( + getExtensionIconLabel(extensions[0]) + )} + </span> + ) : ( + <> + <FontAwesomeIcon icon={faFileCircleExclamation} /> — + </> + )} + </div> + <div className={styles.itemActions}> + <Tooltip title="Delete" arrow placement="top" disableInteractive> + <Button + onClick={handleDraftDelete} + sx={{ + color: 'var(--primary-main)', + minWidth: '34px', + padding: '10px' + }} + variant={'text'} + > + <FontAwesomeIcon icon={faTrash} /> + </Button> + </Tooltip> + </div> + </div> + ) +} diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 71fef1c..ee39939 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -18,7 +18,6 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf' import { useScale } from '../../hooks/useScale' import { AvatarIconButton } from '../UserAvatarIconButton' import { UserAvatar } from '../UserAvatar' -import { Updater } from 'use-immer' import { FileItem } from './internal/FileItem' import { FileDivider } from '../FileDivider' import { Counterpart } from './internal/Counterpart' @@ -28,6 +27,7 @@ const MINIMUM_RECT_SIZE = { height: 10 } as const import { NDKUserProfile } from '@nostr-dev-kit/ndk' +import _ from 'lodash' const DEFAULT_START_SIZE = { width: 140, @@ -45,7 +45,7 @@ interface DrawPdfFieldsProps { users: User[] userProfiles: { [key: string]: NDKUserProfile } sigitFiles: SigitFile[] - updateSigitFiles: Updater<SigitFile[]> + setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>> selectedTool?: DrawTool } @@ -53,11 +53,10 @@ export const DrawPDFFields = ({ selectedTool, userProfiles, sigitFiles, - updateSigitFiles, + setSigitFiles, users }: DrawPdfFieldsProps) => { const { to, from } = useScale() - const signers = users.filter((u) => u.role === UserRole.signer) const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : '' const [lastSigner, setLastSigner] = useState(defaultSignerNpub) @@ -354,8 +353,10 @@ export const DrawPDFFields = ({ ) => { event.stopPropagation() - updateSigitFiles((draft) => { - draft[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1) + setSigitFiles((prev) => { + const clone = _.cloneDeep(prev) + clone[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1) + return clone }) } @@ -416,22 +417,28 @@ export const DrawPDFFields = ({ // Add new drawn field to the files if (mouseState.clicked) { - updateSigitFiles((draft) => { - draft[fileIndex].pages![pageIndex].drawnFields.push(field) + setSigitFiles((prev) => { + const clone = _.cloneDeep(prev) + clone[fileIndex].pages![pageIndex].drawnFields.push(field) + return clone }) } // Move if (mouseState.dragging) { - updateSigitFiles((draft) => { - draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field + setSigitFiles((prev) => { + const clone = _.cloneDeep(prev) + clone[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field + return clone }) } // Resize if (mouseState.resizing) { - updateSigitFiles((draft) => { - draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field + setSigitFiles((prev) => { + const clone = _.cloneDeep(prev) + clone[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field + return clone }) } @@ -446,7 +453,7 @@ export const DrawPDFFields = ({ mouseState.clicked, mouseState.dragging, mouseState.resizing, - updateSigitFiles + setSigitFiles ]) /** diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index d5424a9..60011b7 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -45,7 +45,10 @@ import { uploadToFileStorage, DEFAULT_TOOLBOX, settleAllFullfilfedPromises, - uploadMetaToFileStorage + uploadMetaToFileStorage, + clearSigitDraft, + saveSigitDraft, + getSigitDraft } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -77,7 +80,6 @@ import { AvatarIconButton } from '../../components/UserAvatarIconButton' import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk' import { useNDKContext } from '../../hooks/useNDKContext.ts' import { useNDK } from '../../hooks/useNDK.ts' -import { useImmer } from 'use-immer' import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx' type FoundUser = NostrEvent & { npub: string } @@ -97,7 +99,9 @@ export const CreatePage = () => { const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) - const [selectedFiles, setSelectedFiles] = useState<File[]>([...uploadedFiles]) + const [selectedFiles, setSelectedFiles] = useState<File[]>([ + ...(uploadedFiles || []) + ]) const fileInputRef = useRef<HTMLInputElement>(null) const handleUploadButtonClick = () => { if (fileInputRef.current) { @@ -123,7 +127,7 @@ export const CreatePage = () => { [key: string]: NDKUserProfile }>({}) - const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([]) + const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([]) const [parsingPdf, setIsParsing] = useState<boolean>(false) const searchFieldRef = useRef<HTMLInputElement>(null) @@ -283,27 +287,29 @@ export const CreatePage = () => { selectedFiles, getSigitFile ) - updateDrawnFiles((draft) => { + setDrawnFiles((prev) => { + const clone = _.cloneDeep(prev) // Existing files are untouched // Handle removed files // Remove in reverse to avoid index issues - for (let i = draft.length - 1; i >= 0; i--) { + for (let i = clone.length - 1; i >= 0; i--) { if ( !files.some( - (f) => f.name === draft[i].name && f.size === draft[i].size + (f) => f.name === clone[i].name && f.size === clone[i].size ) ) { - draft.splice(i, 1) + clone.splice(i, 1) } } // Add new files files.forEach((f) => { - if (!draft.some((d) => d.name === f.name && d.size === f.size)) { - draft.push(f) + if (!clone.some((d) => d.name === f.name && d.size === f.size)) { + clone.push(f) } }) + return clone }) } @@ -313,7 +319,52 @@ export const CreatePage = () => { setIsParsing(false) }) } - }, [selectedFiles, updateDrawnFiles]) + }, [selectedFiles]) + + const [draftEnabled, setDraftEnabled] = useState(true) + useEffect(() => { + // Only proceed if we have no uploaded files + if (uploadedFiles?.length ?? 0) return + + getSigitDraft().then((draft) => { + if (draft) { + setSelectedFiles(draft.files) + setDrawnFiles((prev) => { + const clone = _.cloneDeep(prev) + clone.splice(0, clone.length, ...draft.files) + return clone + }) + setUsers(draft.users) + setTitle(draft.title) + + // After loading draft clear it + clearSigitDraft() + } + }) + }, [uploadedFiles]) + useEffect(() => { + if (draftEnabled) { + saveSigitDraft({ + title, + users, + lastUpdated: Date.now(), + files: drawnFiles + }).catch((error) => { + if ( + error instanceof DOMException && + error.name === 'QuotaExceededError' + ) { + // Disable draft if we hit size error + setDraftEnabled(false) + console.warn( + 'Draft functionality disabled temporarily. File size exceeds local storage limit.' + ) + clearSigitDraft() + } + // Ignore other errors + }) + } + }, [draftEnabled, drawnFiles, title, users]) /** * Changes the drawing tool @@ -504,7 +555,7 @@ export const CreatePage = () => { }) }) }) - updateDrawnFiles(drawnFilesCopy) + setDrawnFiles(drawnFilesCopy) } /** @@ -940,6 +991,7 @@ export const CreatePage = () => { console.error(error) } finally { setIsLoading(false) + clearSigitDraft() } } @@ -1017,6 +1069,7 @@ export const CreatePage = () => { console.error(error) } finally { setIsLoading(false) + clearSigitDraft() } } @@ -1285,7 +1338,7 @@ export const CreatePage = () => { userProfiles={userProfiles} selectedTool={selectedTool} sigitFiles={drawnFiles} - updateSigitFiles={updateDrawnFiles} + setSigitFiles={setDrawnFiles} /> {parsingPdf && <LoadingSpinner variant="small" />} </StickySideColumns> diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index abd3b4e..44779b4 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -2,7 +2,7 @@ import { Button, TextField } from '@mui/material' import { useCallback, useEffect, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' -import { useAppSelector } from '../../hooks' +import { useAppSelector, useDidMount } from '../../hooks' import { appPrivateRoutes } from '../../routes' import { Meta } from '../../types' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -13,12 +13,15 @@ import { useDropzone } from 'react-dropzone' import { Container } from '../../components/Container' import styles from './style.module.scss' import { + clearSigitDraft, extractSigitCardDisplayInfo, + hasSigitDraft, navigateFromZip, SigitCardDisplayInfo, SigitStatus } from '../../utils' import { Footer } from '../../components/Footer/Footer' +import { LocalDraftSigit } from '../../components/DisplaySigit/LocalDraftSigit' // Unsupported Filter options are commented const FILTERS = [ @@ -44,6 +47,12 @@ export const HomePage = () => { const [searchParams, setSearchParams] = useSearchParams() const q = searchParams.get('q') ?? '' + const [showDraft, setShowDraft] = useState<boolean>(false) + useDidMount(async () => { + // Check if draft exists and add link to direct + setShowDraft(hasSigitDraft()) + }) + useEffect(() => { const searchInput = document.getElementById('q') as HTMLInputElement | null if (searchInput) { @@ -152,7 +161,7 @@ export const HomePage = () => { meta={sigits[key]} /> )) - } else { + } else if (!showDraft) { return ( <div className={styles.noResults}> <p>No results</p> @@ -260,7 +269,17 @@ export const HomePage = () => { )} </button> - <div className={styles.submissions}>{renderSubmissions()}</div> + <div className={styles.submissions}> + {showDraft && ( + <LocalDraftSigit + handleDraftDelete={() => { + clearSigitDraft() + setShowDraft(false) + }} + /> + )} + {renderSubmissions()} + </div> </Container> <Footer /> </div> diff --git a/src/types/draft.ts b/src/types/draft.ts index 900d87e..23709a7 100644 --- a/src/types/draft.ts +++ b/src/types/draft.ts @@ -11,9 +11,11 @@ export interface SigitDraft { title: string users: User[] files: SigitFile[] + lastUpdated: number } export interface SerializedSigitDraft { title: string + lastUpdated: number users: User[] files: SigitFileDraft[] } diff --git a/src/utils/draft.ts b/src/utils/draft.ts index 4e9ae56..e9ba239 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -10,7 +10,7 @@ import { toFile, getSigitFile } from './file' - +const DRAFT_KEY = 'sigitDraft' let saveSigitDraftTimeout: number | null = null const serializeSigitDraft = async ( draft: SigitDraft @@ -48,6 +48,7 @@ const serializeSigitDraft = async ( const serializedFileDraft = await Promise.all(serializedFiles) return { title: draft.title, + lastUpdated: draft.lastUpdated, users: [...draft.users], files: serializedFileDraft } @@ -79,24 +80,31 @@ const deserializeSigitDraft = async ( } } -export const saveSigitDraft = (draft: SigitDraft) => { +export const saveSigitDraft = (draft: SigitDraft): Promise<void> => { if (saveSigitDraftTimeout) { clearTimeout(saveSigitDraftTimeout) } - saveSigitDraftTimeout = window.setTimeout(() => { - serializeSigitDraft(draft) - .then((draftToSave) => { - localStorage.setItem('sigitDraft', JSON.stringify(draftToSave)) - }) - .catch((error) => { - console.log(`Error while saving sigit draft. Error: `, error) - }) - }, 1000) + return new Promise((resolve, reject) => { + saveSigitDraftTimeout = window.setTimeout(() => { + serializeSigitDraft(draft) + .then((draftToSave) => { + localStorage.setItem(DRAFT_KEY, JSON.stringify(draftToSave)) + resolve() + }) + .catch((error) => { + reject(error) + }) + }, 1000) + }) +} + +export const hasSigitDraft = () => { + return DRAFT_KEY in localStorage } export const getSigitDraft = async () => { - const sigitDraft = localStorage.getItem('sigitDraft') + const sigitDraft = localStorage.getItem(DRAFT_KEY) if (!sigitDraft) return null try { @@ -108,5 +116,5 @@ export const getSigitDraft = async () => { } export const clearSigitDraft = () => { - localStorage.removeItem('sigitDraft') + localStorage.removeItem(DRAFT_KEY) } diff --git a/src/utils/meta.ts b/src/utils/meta.ts index 75e1654..fd68541 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -31,7 +31,8 @@ export enum SignStatus { export enum SigitStatus { Partial = 'In-Progress', - Complete = 'Completed' + Complete = 'Completed', + LocalDraft = 'Draft' } export interface SigitCardDisplayInfo { From f422ee338c96a926947396ed3febb3ecf6c9495b Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Tue, 18 Feb 2025 17:45:50 +0100 Subject: [PATCH 14/28] chore: remove extra comment whitespace --- src/utils/nostr.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 3d8aa15..824a967 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -270,7 +270,7 @@ export const countLeadingZeroes = (hex: string) => { /** * Function to create a wrapped event with PoW - * @param event Original event to be wrapped (can be unsigned or verified) + * @param event Original event to be wrapped (can be unsigned or verified) * @param receiver Public key of the receiver * @returns */ From 08b13c291b3cae097e01416127e8674e53b59c53 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Tue, 18 Feb 2025 18:38:54 +0100 Subject: [PATCH 15/28] fix: hide DisplaySigit actions Closes #246 --- src/components/DisplaySigit/index.tsx | 57 +++++++++++++++------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index 5147b45..20550b3 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -112,32 +112,37 @@ export const DisplaySigit = ({ </> )} </div> - <div className={styles.itemActions}> - <Tooltip title="Duplicate" arrow placement="top" disableInteractive> - <Button - sx={{ - color: 'var(--primary-main)', - minWidth: '34px', - padding: '10px' - }} - variant={'text'} - > - <FontAwesomeIcon icon={faCopy} /> - </Button> - </Tooltip> - <Tooltip title="Archive" arrow placement="top" disableInteractive> - <Button - sx={{ - color: 'var(--primary-main)', - minWidth: '34px', - padding: '10px' - }} - variant={'text'} - > - <FontAwesomeIcon icon={faArchive} /> - </Button> - </Tooltip> - </div> + { + // TODO: enable buttons once feature is ready + false && ( + <div className={styles.itemActions}> + <Tooltip title="Duplicate" arrow placement="top" disableInteractive> + <Button + sx={{ + color: 'var(--primary-main)', + minWidth: '34px', + padding: '10px' + }} + variant={'text'} + > + <FontAwesomeIcon icon={faCopy} /> + </Button> + </Tooltip> + <Tooltip title="Archive" arrow placement="top" disableInteractive> + <Button + sx={{ + color: 'var(--primary-main)', + minWidth: '34px', + padding: '10px' + }} + variant={'text'} + > + <FontAwesomeIcon icon={faArchive} /> + </Button> + </Tooltip> + </div> + ) + } </div> ) } From 4b5625e5bd1354678bdade46ee511be187fe754b Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Wed, 19 Feb 2025 10:54:58 +0100 Subject: [PATCH 16/28] fix(search): intercept nsec1, delete, and show warning --- src/pages/create/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 2d476ae..2ee1985 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -1045,6 +1045,11 @@ export const CreatePage = () => { // Seems like it's npub format if (value.trim().startsWith('npub1')) { setPastedUserNpubOrNip05(value.trim()) + } else if (value.trim().startsWith('nsec1')) { + toast.warn('Oops - never paste your nsec into a website! Key deleted.') + if (searchFieldRef.current) searchFieldRef.current.value = '' + setUserSearchInput('') + return } else { // Disarm the add user on enter hit, and trigger search after 1 second disarmAddOnEnter() From cc65d85806b57ac271deef7e0fc31df44935c80a Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 7 Mar 2025 11:42:09 +0000 Subject: [PATCH 17/28] refactor(styles): update css for other marks during sign --- src/components/PDFView/style.module.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/PDFView/style.module.scss b/src/components/PDFView/style.module.scss index 61983d7..92c044e 100644 --- a/src/components/PDFView/style.module.scss +++ b/src/components/PDFView/style.module.scss @@ -8,6 +8,4 @@ position: absolute; z-index: 40; display: flex; - justify-content: center; - align-items: center; } From 8e23a2d8a1c05a760f0233db49a3e0c1ccc2af38 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 7 Mar 2025 11:43:01 +0000 Subject: [PATCH 18/28] refactor: remove custom sigit cache page and links --- src/pages/settings/Settings.tsx | 7 ------- src/routes/index.tsx | 1 - 2 files changed, 8 deletions(-) diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index 5acdd9c..1382bc1 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -1,6 +1,5 @@ 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 { ListItem, useTheme } from '@mui/material' import List from '@mui/material/List' @@ -74,12 +73,6 @@ export const SettingsPage = () => { </ListItemIcon> {listItem('Relays')} </ListItem> - <ListItem component={Link} to={appPrivateRoutes.cacheSettings}> - <ListItemIcon> - <CachedIcon /> - </ListItemIcon> - {listItem('Local Cache')} - </ListItem> {loginMethod === LoginMethod.nostrLogin && ( <ListItem component={Link} to={appPrivateRoutes.nostrLogin}> <ListItemIcon> diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f3580f9..f514e78 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -6,7 +6,6 @@ export const appPrivateRoutes = { sign: '/sign', settings: '/settings', profileSettings: '/settings/profile/:npub', - cacheSettings: '/settings/cache', relays: '/settings/relays', nostrLogin: '/settings/nostrLogin' } From cc681af11a34619ce6c41f6316b4fe9e831ff6c2 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 7 Mar 2025 12:15:26 +0000 Subject: [PATCH 19/28] feat(marks): add full name --- .../MarkTypeStrategy/FullName/Input.tsx | 20 ++++++ .../MarkTypeStrategy/FullName/index.tsx | 7 ++ .../MarkTypeStrategy/MarkStrategy.tsx | 4 +- src/hooks/useLocalStorage.ts | 64 +++++++++++++++++++ src/utils/localStorage.ts | 44 +++++++++++++ src/utils/mark.ts | 3 +- 6 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/components/MarkTypeStrategy/FullName/Input.tsx create mode 100644 src/components/MarkTypeStrategy/FullName/index.tsx create mode 100644 src/hooks/useLocalStorage.ts diff --git a/src/components/MarkTypeStrategy/FullName/Input.tsx b/src/components/MarkTypeStrategy/FullName/Input.tsx new file mode 100644 index 0000000..7b63ae6 --- /dev/null +++ b/src/components/MarkTypeStrategy/FullName/Input.tsx @@ -0,0 +1,20 @@ +import { useDidMount } from '../../../hooks' +import { useLocalStorage } from '../../../hooks/useLocalStorage' +import { MarkInputProps } from '../MarkStrategy' +import { MarkInputText } from '../Text/Input' + +export const MarkInputFullName = (props: MarkInputProps) => { + const [fullName, setFullName] = useLocalStorage('mark-fullname', '') + useDidMount(() => { + props.handler(fullName) + }) + return MarkInputText({ + ...props, + placeholder: 'Full Name', + value: fullName, + handler: (value) => { + setFullName(value) + props.handler(value) + } + }) +} diff --git a/src/components/MarkTypeStrategy/FullName/index.tsx b/src/components/MarkTypeStrategy/FullName/index.tsx new file mode 100644 index 0000000..1574c42 --- /dev/null +++ b/src/components/MarkTypeStrategy/FullName/index.tsx @@ -0,0 +1,7 @@ +import { MarkStrategy } from '../MarkStrategy' +import { MarkInputFullName } from './Input' + +export const FullNameStrategy: MarkStrategy = { + input: MarkInputFullName, + render: ({ value }) => <>{value}</> +} diff --git a/src/components/MarkTypeStrategy/MarkStrategy.tsx b/src/components/MarkTypeStrategy/MarkStrategy.tsx index 562302e..f842220 100644 --- a/src/components/MarkTypeStrategy/MarkStrategy.tsx +++ b/src/components/MarkTypeStrategy/MarkStrategy.tsx @@ -2,6 +2,7 @@ import { MarkType } from '../../types/drawing' import { CurrentUserMark, Mark } from '../../types/mark' import { TextStrategy } from './Text' import { SignatureStrategy } from './Signature' +import { FullNameStrategy } from './FullName' export interface MarkInputProps { value: string @@ -28,5 +29,6 @@ export type MarkStrategies = { export const MARK_TYPE_CONFIG: MarkStrategies = { [MarkType.TEXT]: TextStrategy, - [MarkType.SIGNATURE]: SignatureStrategy + [MarkType.SIGNATURE]: SignatureStrategy, + [MarkType.FULLNAME]: FullNameStrategy } diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..ec6c9cb --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,64 @@ +import React, { useMemo } from 'react' +import { + getLocalStorageItem, + mergeWithInitialValue, + removeLocalStorageItem, + setLocalStorageItem +} from '../utils' + +const useLocalStorageSubscribe = (callback: () => void) => { + window.addEventListener('storage', callback) + return () => window.removeEventListener('storage', callback) +} + +export function useLocalStorage<T>( + key: string, + initialValue: T +): [T, React.Dispatch<React.SetStateAction<T>>] { + const getSnapshot = () => { + // Get the stored value + const storedValue = getLocalStorageItem(key, initialValue) + + // Parse the value + const parsedStoredValue = JSON.parse(storedValue) + + // Merge the default and the stored in case some of the required fields are missing + return JSON.stringify( + mergeWithInitialValue(parsedStoredValue, initialValue) + ) + } + + const data = React.useSyncExternalStore(useLocalStorageSubscribe, getSnapshot) + + const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback( + (v: React.SetStateAction<T>) => { + try { + const nextState = + typeof v === 'function' + ? (v as (prevState: T) => T)(JSON.parse(data)) + : v + + if (nextState === undefined || nextState === null) { + removeLocalStorageItem(key) + } else { + setLocalStorageItem(key, JSON.stringify(nextState)) + } + } catch (e) { + console.warn(e) + } + }, + [data, key] + ) + + React.useEffect(() => { + // Set local storage only when it's empty + const data = window.localStorage.getItem(key) + if (data === null) { + setLocalStorageItem(key, JSON.stringify(initialValue)) + } + }, [key, initialValue]) + + const memoized = useMemo(() => JSON.parse(data) as T, [data]) + + return [memoized, setState] +} diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index 8196e35..b0eb381 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -42,3 +42,47 @@ export const clear = () => { clearAuthToken() clearState() } + +export function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T { + if ( + !Array.isArray(storedValue) && + typeof storedValue === 'object' && + storedValue !== null + ) { + return { ...initialValue, ...storedValue } + } + return storedValue +} + +export function getLocalStorageItem<T>(key: string, defaultValue: T): string { + try { + const data = window.localStorage.getItem(key) + if (data === null) return JSON.stringify(defaultValue) + return data + } catch (err) { + console.error(`Error while fetching local storage value: `, err) + return JSON.stringify(defaultValue) + } +} + +export function setLocalStorageItem(key: string, value: string) { + try { + window.localStorage.setItem(key, value) + dispatchLocalStorageEvent(key, value) + } catch (err) { + console.error(`Error while saving local storage value: `, err) + } +} + +export function removeLocalStorageItem(key: string) { + try { + window.localStorage.removeItem(key) + dispatchLocalStorageEvent(key, null) + } catch (err) { + console.error(`Error while deleting local storage value: `, err) + } +} + +function dispatchLocalStorageEvent(key: string, newValue: string | null) { + window.dispatchEvent(new StorageEvent('storage', { key, newValue })) +} diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 1868403..319371c 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -171,8 +171,7 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [ { identifier: MarkType.FULLNAME, icon: faIdCard, - label: 'Full Name', - isComingSoon: true + label: 'Full Name' }, { identifier: MarkType.JOBTITLE, From c8f0d135f13dfea86068f7efb6c4c7152b299085 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Fri, 7 Mar 2025 12:47:55 +0000 Subject: [PATCH 20/28] feat(marks): add job title and datetime --- .../MarkTypeStrategy/DateTime/Input.tsx | 25 +++++++++++++++++++ .../MarkTypeStrategy/DateTime/index.tsx | 7 ++++++ .../MarkTypeStrategy/JobTitle/Input.tsx | 20 +++++++++++++++ .../MarkTypeStrategy/JobTitle/index.tsx | 7 ++++++ .../MarkTypeStrategy/MarkStrategy.tsx | 6 ++++- src/utils/mark.ts | 6 ++--- 6 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 src/components/MarkTypeStrategy/DateTime/Input.tsx create mode 100644 src/components/MarkTypeStrategy/DateTime/index.tsx create mode 100644 src/components/MarkTypeStrategy/JobTitle/Input.tsx create mode 100644 src/components/MarkTypeStrategy/JobTitle/index.tsx diff --git a/src/components/MarkTypeStrategy/DateTime/Input.tsx b/src/components/MarkTypeStrategy/DateTime/Input.tsx new file mode 100644 index 0000000..fa68438 --- /dev/null +++ b/src/components/MarkTypeStrategy/DateTime/Input.tsx @@ -0,0 +1,25 @@ +import { MarkInputProps } from '../MarkStrategy' +import styles from '../../MarkFormField/style.module.scss' +import { useEffect, useRef } from 'react' + +export const MarkInputDateTime = ({ handler, placeholder }: MarkInputProps) => { + const ref = useRef<HTMLInputElement>(null) + useEffect(() => { + if (ref.current) { + ref.current.value = new Date().toISOString().slice(0, 16) + if (ref.current.valueAsDate) { + handler(ref.current.valueAsDate.toUTCString()) + } + } + }, [handler]) + return ( + <input + type="datetime-local" + ref={ref} + className={styles.input} + placeholder={placeholder} + readOnly={true} + disabled={true} + /> + ) +} diff --git a/src/components/MarkTypeStrategy/DateTime/index.tsx b/src/components/MarkTypeStrategy/DateTime/index.tsx new file mode 100644 index 0000000..1892d49 --- /dev/null +++ b/src/components/MarkTypeStrategy/DateTime/index.tsx @@ -0,0 +1,7 @@ +import { MarkStrategy } from '../MarkStrategy' +import { MarkInputDateTime } from './Input' + +export const DateTimeStrategy: MarkStrategy = { + input: MarkInputDateTime, + render: ({ value }) => <>{value}</> +} diff --git a/src/components/MarkTypeStrategy/JobTitle/Input.tsx b/src/components/MarkTypeStrategy/JobTitle/Input.tsx new file mode 100644 index 0000000..47d2969 --- /dev/null +++ b/src/components/MarkTypeStrategy/JobTitle/Input.tsx @@ -0,0 +1,20 @@ +import { useDidMount } from '../../../hooks' +import { useLocalStorage } from '../../../hooks/useLocalStorage' +import { MarkInputProps } from '../MarkStrategy' +import { MarkInputText } from '../Text/Input' + +export const MarkInputJobTitle = (props: MarkInputProps) => { + const [jobTitle, setjobTitle] = useLocalStorage('mark-jobtitle', '') + useDidMount(() => { + props.handler(jobTitle) + }) + return MarkInputText({ + ...props, + placeholder: 'Job Title', + value: jobTitle, + handler: (value) => { + setjobTitle(value) + props.handler(value) + } + }) +} diff --git a/src/components/MarkTypeStrategy/JobTitle/index.tsx b/src/components/MarkTypeStrategy/JobTitle/index.tsx new file mode 100644 index 0000000..11f5d60 --- /dev/null +++ b/src/components/MarkTypeStrategy/JobTitle/index.tsx @@ -0,0 +1,7 @@ +import { MarkStrategy } from '../MarkStrategy' +import { MarkInputJobTitle } from './Input' + +export const JobTitleStrategy: MarkStrategy = { + input: MarkInputJobTitle, + render: ({ value }) => <>{value}</> +} diff --git a/src/components/MarkTypeStrategy/MarkStrategy.tsx b/src/components/MarkTypeStrategy/MarkStrategy.tsx index f842220..0ca0ebc 100644 --- a/src/components/MarkTypeStrategy/MarkStrategy.tsx +++ b/src/components/MarkTypeStrategy/MarkStrategy.tsx @@ -3,6 +3,8 @@ import { CurrentUserMark, Mark } from '../../types/mark' import { TextStrategy } from './Text' import { SignatureStrategy } from './Signature' import { FullNameStrategy } from './FullName' +import { JobTitleStrategy } from './JobTitle' +import { DateTimeStrategy } from './DateTime' export interface MarkInputProps { value: string @@ -30,5 +32,7 @@ export type MarkStrategies = { export const MARK_TYPE_CONFIG: MarkStrategies = { [MarkType.TEXT]: TextStrategy, [MarkType.SIGNATURE]: SignatureStrategy, - [MarkType.FULLNAME]: FullNameStrategy + [MarkType.FULLNAME]: FullNameStrategy, + [MarkType.JOBTITLE]: JobTitleStrategy, + [MarkType.DATETIME]: DateTimeStrategy } diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 319371c..37bda6b 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -176,14 +176,12 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [ { identifier: MarkType.JOBTITLE, icon: faBriefcase, - label: 'Job Title', - isComingSoon: true + label: 'Job Title' }, { identifier: MarkType.DATETIME, icon: faClock, - label: 'Date Time', - isComingSoon: true + label: 'Date Time' }, { identifier: MarkType.NUMBER, From 8de86aac28c38fc4fb8d34eca04e0ce50b6ab13e Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Mon, 10 Mar 2025 09:44:09 +0000 Subject: [PATCH 21/28] fix(marks): date input --- src/components/MarkTypeStrategy/DateTime/Input.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/MarkTypeStrategy/DateTime/Input.tsx b/src/components/MarkTypeStrategy/DateTime/Input.tsx index fa68438..b2e864c 100644 --- a/src/components/MarkTypeStrategy/DateTime/Input.tsx +++ b/src/components/MarkTypeStrategy/DateTime/Input.tsx @@ -6,10 +6,9 @@ export const MarkInputDateTime = ({ handler, placeholder }: MarkInputProps) => { const ref = useRef<HTMLInputElement>(null) useEffect(() => { if (ref.current) { - ref.current.value = new Date().toISOString().slice(0, 16) - if (ref.current.valueAsDate) { - handler(ref.current.valueAsDate.toUTCString()) - } + const date = new Date() + ref.current.value = date.toISOString().slice(0, 16) + handler(date.toUTCString()) } }, [handler]) return ( From 745ba377d4d10d3df1ecb4ae2731bf8229385c79 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Mon, 10 Mar 2025 10:56:36 +0000 Subject: [PATCH 22/28] refactor(settings): remove cache links and page --- src/pages/settings/cache/index.tsx | 69 ------------------------------ src/routes/util.tsx | 9 ---- 2 files changed, 78 deletions(-) delete mode 100644 src/pages/settings/cache/index.tsx diff --git a/src/pages/settings/cache/index.tsx b/src/pages/settings/cache/index.tsx deleted file mode 100644 index 5dbf8af..0000000 --- a/src/pages/settings/cache/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 { Container } from '../../../components/Container' -import { Footer } from '../../../components/Footer/Footer' - -export const CacheSettingsPage = () => { - const theme = useTheme() - - const listItem = (label: string) => { - return ( - <ListItemText - primary={label} - sx={{ - color: theme.palette.text.primary - }} - /> - ) - } - - return ( - <> - <Container> - <List - sx={{ - width: '100%', - bgcolor: 'background.paper', - marginTop: 2 - }} - subheader={ - <ListSubheader - sx={{ - fontSize: '1.5rem', - borderBottom: '0.5px solid', - paddingBottom: 2, - paddingTop: 2, - zIndex: 2 - }} - > - Cache Setting - </ListSubheader> - } - > - <ListItemButton disabled> - <ListItemIcon> - <IosShareIcon /> - </ListItemIcon> - {listItem('Export (coming soon)')} - </ListItemButton> - - <ListItemButton disabled> - <ListItemIcon> - <InputIcon /> - </ListItemIcon> - {listItem('Import (coming soon)')} - </ListItemButton> - </List> - </Container> - <Footer /> - </> - ) -} diff --git a/src/routes/util.tsx b/src/routes/util.tsx index 2e1be26..e21dc13 100644 --- a/src/routes/util.tsx +++ b/src/routes/util.tsx @@ -4,7 +4,6 @@ import { CreatePage } from '../pages/create' import { HomePage } from '../pages/home' import { LandingPage } from '../pages/landing' import { ProfilePage } from '../pages/profile' -import { CacheSettingsPage } from '../pages/settings/cache' import { NostrLoginPage } from '../pages/settings/nostrLogin' import { ProfileSettingsPage } from '../pages/settings/profile' import { RelaysPage } from '../pages/settings/relays' @@ -109,14 +108,6 @@ export const privateRoutes = [ </PrivateRoute> ) }, - { - path: appPrivateRoutes.cacheSettings, - element: ( - <PrivateRoute> - <CacheSettingsPage /> - </PrivateRoute> - ) - }, { path: appPrivateRoutes.relays, element: ( From c1a9475a89bb07ec522a95fede276d09eafd0813 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Mon, 10 Mar 2025 11:57:08 +0000 Subject: [PATCH 23/28] refactor(settings): update settings layout --- src/pages/settings/Settings.tsx | 131 ++++++----- src/pages/settings/nostrLogin/index.tsx | 62 ++---- src/pages/settings/profile/index.tsx | 217 +++++++++---------- src/pages/settings/profile/style.module.scss | 6 - src/pages/settings/relays/index.tsx | 7 +- src/pages/settings/relays/style.module.scss | 170 +++++++-------- src/pages/settings/style.module.scss | 43 ++++ src/routes/util.tsx | 44 ++-- 8 files changed, 325 insertions(+), 355 deletions(-) create mode 100644 src/pages/settings/style.module.scss diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index 1382bc1..f00eab4 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -1,87 +1,82 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle' -import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' import RouterIcon from '@mui/icons-material/Router' -import { ListItem, useTheme } from '@mui/material' -import List from '@mui/material/List' -import ListItemIcon from '@mui/material/ListItemIcon' -import ListItemText from '@mui/material/ListItemText' -import ListSubheader from '@mui/material/ListSubheader' +import { Button } from '@mui/material' import { useAppSelector } from '../../hooks/store' -import { Link } from 'react-router-dom' +import { NavLink, Outlet, To } from 'react-router-dom' import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes' import { Container } from '../../components/Container' import { Footer } from '../../components/Footer/Footer' import ExtensionIcon from '@mui/icons-material/Extension' import { LoginMethod } from '../../store/auth/types' +import styles from './style.module.scss' +import { ReactNode } from 'react' -export const SettingsPage = () => { - const theme = useTheme() - const { usersPubkey, loginMethod } = useAppSelector((state) => state.auth) - const listItem = (label: string, disabled = false) => { - return ( - <> - <ListItemText - primary={label} +const Item = (to: To, icon: ReactNode, label: string) => { + return ( + <NavLink to={to}> + {({ isActive }) => ( + <Button + fullWidth sx={{ - color: theme.palette.text.primary + transition: 'ease 0.3s', + justifyContent: 'start', + gap: '10px', + background: 'rgba(76,130,163,0)', + color: '#434343', + fontWeight: 600, + opacity: 0.75, + textTransform: 'none', + ...(isActive + ? { + background: '#447592', + color: 'white' + } + : {}), + '&:hover': { + opacity: 0.85, + gap: '15px', + background: '#5e8eab', + color: 'white' + } }} - /> + variant={'text'} + > + {icon} + {label} + </Button> + )} + </NavLink> + ) +} - {!disabled && ( - <ArrowForwardIosIcon - style={{ - color: theme.palette.action.active, - marginRight: -10 - }} - /> - )} - </> - ) - } +export const SettingsLayout = () => { + const { usersPubkey, loginMethod } = useAppSelector((state) => state.auth) return ( <> <Container> - <List - sx={{ - width: '100%', - bgcolor: 'background.paper' - }} - subheader={ - <ListSubheader - sx={{ - fontSize: '1.5rem', - borderBottom: '0.5px solid', - paddingBottom: 2, - paddingTop: 2, - zIndex: 2 - }} - > - Settings - </ListSubheader> - } - > - <ListItem component={Link} to={getProfileSettingsRoute(usersPubkey!)}> - <ListItemIcon> - <AccountCircleIcon /> - </ListItemIcon> - {listItem('Profile')} - </ListItem> - <ListItem component={Link} to={appPrivateRoutes.relays}> - <ListItemIcon> - <RouterIcon /> - </ListItemIcon> - {listItem('Relays')} - </ListItem> - {loginMethod === LoginMethod.nostrLogin && ( - <ListItem component={Link} to={appPrivateRoutes.nostrLogin}> - <ListItemIcon> - <ExtensionIcon /> - </ListItemIcon> - {listItem('Nostr Login')} - </ListItem> - )} - </List> + <h2 className={styles.title}>Settings</h2> + <div className={styles.main}> + <div> + <aside className={styles.aside}> + {Item( + getProfileSettingsRoute(usersPubkey!), + <AccountCircleIcon />, + 'Profile' + )} + {Item(appPrivateRoutes.relays, <RouterIcon />, 'Relays')} + {loginMethod === LoginMethod.nostrLogin && + Item( + appPrivateRoutes.nostrLogin, + <ExtensionIcon />, + 'Nostr Login' + )} + </aside> + </div> + <div className={styles.content}> + <Outlet /> + </div> + </div> </Container> <Footer /> </> diff --git a/src/pages/settings/nostrLogin/index.tsx b/src/pages/settings/nostrLogin/index.tsx index d2f0d29..31434ac 100644 --- a/src/pages/settings/nostrLogin/index.tsx +++ b/src/pages/settings/nostrLogin/index.tsx @@ -3,11 +3,9 @@ import { ListItemButton, ListItemIcon, ListItemText, - ListSubheader, useTheme } from '@mui/material' import { launch as launchNostrLoginDialog } from 'nostr-login' -import { Container } from '../../../components/Container' import PeopleIcon from '@mui/icons-material/People' import ImportExportIcon from '@mui/icons-material/ImportExport' import { useAppSelector } from '../../../hooks/store' @@ -20,59 +18,39 @@ export const NostrLoginPage = () => { ) return ( - <Container> - <List - sx={{ - width: '100%', - bgcolor: 'background.paper' + <List> + <ListItemButton + onClick={() => { + launchNostrLoginDialog('switch-account') }} - subheader={ - <ListSubheader - sx={{ - fontSize: '1.5rem', - borderBottom: '0.5px solid', - paddingBottom: 2, - paddingTop: 2, - zIndex: 2 - }} - > - Nostr Settings - </ListSubheader> - } > + <ListItemIcon> + <PeopleIcon /> + </ListItemIcon> + <ListItemText + primary={'Nostr Login Accounts'} + sx={{ + color: theme.palette.text.primary + }} + /> + </ListItemButton> + {nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( <ListItemButton onClick={() => { - launchNostrLoginDialog('switch-account') + launchNostrLoginDialog('import') }} > <ListItemIcon> - <PeopleIcon /> + <ImportExportIcon /> </ListItemIcon> <ListItemText - primary={'Nostr Login Accounts'} + primary={'Import / Export Keys'} sx={{ color: theme.palette.text.primary }} /> </ListItemButton> - {nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( - <ListItemButton - onClick={() => { - launchNostrLoginDialog('import') - }} - > - <ListItemIcon> - <ImportExportIcon /> - </ListItemIcon> - <ListItemText - primary={'Import / Export Keys'} - sx={{ - color: theme.palette.text.primary - }} - /> - </ListItemButton> - )} - </List> - </Container> + )} + </List> ) } diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 57383a7..03db00f 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -12,7 +12,6 @@ import { InputProps, List, ListItem, - ListSubheader, TextField, Tooltip } from '@mui/material' @@ -28,8 +27,6 @@ import { useAppDispatch, useAppSelector } from '../../../hooks/store' import { getRoboHashPicture, unixNow } from '../../../utils' -import { Container } from '../../../components/Container' -import { Footer } from '../../../components/Footer/Footer' import { LoadingSpinner } from '../../../components/LoadingSpinner' import { setUserProfile as updateUserProfile } from '../../../store/actions' @@ -256,131 +253,111 @@ export const ProfileSettingsPage = () => { return ( <> {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} - <Container className={styles.container}> - <List - sx={{ - bgcolor: 'background.paper', - marginTop: 2 - }} - subheader={ - <ListSubheader + <List> + {userProfile && ( + <div> + <ListItem sx={{ - paddingBottom: 1, - paddingTop: 1, - fontSize: '1.5rem', - zIndex: 2 + marginTop: 1, + display: 'flex', + flexDirection: 'column' }} - className={styles.subHeader} > - Profile Settings - </ListSubheader> - } - > - {userProfile && ( - <div> - <ListItem - sx={{ - marginTop: 1, - display: 'flex', - flexDirection: 'column' - }} - > - {userProfile.banner ? ( - <img - className={styles.bannerImg} - src={userProfile.banner} - alt="Banner Image" - /> - ) : ( - <Box className={styles.noBanner}> No banner found </Box> - )} - </ListItem> - - {editItem('banner', 'Banner URL', undefined, undefined)} - - <ListItem - sx={{ - marginTop: 1, - display: 'flex', - flexDirection: 'column' - }} - > + {userProfile.banner ? ( <img - onError={(event: React.SyntheticEvent<HTMLImageElement>) => { - event.currentTarget.src = getRoboHashPicture(npub!) - }} - className={styles.img} - src={getProfileImage(userProfile)} - alt="Profile Image" + className={styles.bannerImg} + src={userProfile.banner} + alt="Banner Image" /> - </ListItem> - - {editItem('image', 'Picture URL', undefined, undefined, { - endAdornment: isUsersOwnProfile ? robohashButton() : undefined - })} - - {editItem('name', 'Username')} - {editItem('displayName', 'Display Name')} - {editItem('nip05', 'Nostr Address (nip05)')} - {editItem('lud16', 'Lightning Address (lud16)')} - {editItem('about', 'About', true, 4)} - {editItem('website', 'Website')} - {isUsersOwnProfile && ( - <> - {usersPubkey && - copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} - - {loginMethod === LoginMethod.privateKey && - keys && - keys.private && - copyItem( - '••••••••••••••••••••••••••••••••••••••••••••••••••', - 'Private Key', - keys.private - )} - </> + ) : ( + <Box className={styles.noBanner}> No banner found </Box> )} - {isUsersOwnProfile && ( - <> - {loginMethod === LoginMethod.nostrLogin && - nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( - <ListItem - sx={{ marginTop: 1 }} - onClick={() => { - launchNostrLoginDialog('import') + </ListItem> + + {editItem('banner', 'Banner URL', undefined, undefined)} + + <ListItem + sx={{ + marginTop: 1, + display: 'flex', + flexDirection: 'column' + }} + > + <img + onError={(event: React.SyntheticEvent<HTMLImageElement>) => { + event.currentTarget.src = getRoboHashPicture(npub!) + }} + className={styles.img} + src={getProfileImage(userProfile)} + alt="Profile Image" + /> + </ListItem> + + {editItem('image', 'Picture URL', undefined, undefined, { + endAdornment: isUsersOwnProfile ? robohashButton() : undefined + })} + + {editItem('name', 'Username')} + {editItem('displayName', 'Display Name')} + {editItem('nip05', 'Nostr Address (nip05)')} + {editItem('lud16', 'Lightning Address (lud16)')} + {editItem('about', 'About', true, 4)} + {editItem('website', 'Website')} + {isUsersOwnProfile && ( + <> + {usersPubkey && + copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} + + {loginMethod === LoginMethod.privateKey && + keys && + keys.private && + copyItem( + '••••••••••••••••••••••••••••••••••••••••••••••••••', + 'Private Key', + keys.private + )} + </> + )} + {isUsersOwnProfile && ( + <> + {loginMethod === LoginMethod.nostrLogin && + nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( + <ListItem + sx={{ marginTop: 1 }} + onClick={() => { + launchNostrLoginDialog('import') + }} + > + <TextField + label="Private Key (nostr-login)" + defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••" + size="small" + className={styles.textField} + disabled + type={'password'} + InputProps={{ + endAdornment: ( + <LaunchIcon className={styles.copyItem} /> + ) }} - > - <TextField - label="Private Key (nostr-login)" - defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••" - size="small" - className={styles.textField} - disabled - type={'password'} - InputProps={{ - endAdornment: ( - <LaunchIcon className={styles.copyItem} /> - ) - }} - /> - </ListItem> - )} - </> - )} - </div> - )} - </List> - {isUsersOwnProfile && ( - <LoadingButton - loading={savingProfileMetadata} - variant="contained" - onClick={handleSaveMetadata} - > - SAVE - </LoadingButton> + /> + </ListItem> + )} + </> + )} + </div> )} - </Container> - <Footer /> + </List> + {isUsersOwnProfile && ( + <LoadingButton + sx={{ maxWidth: '300px', alignSelf: 'center', width: '100%' }} + loading={savingProfileMetadata} + variant="contained" + onClick={handleSaveMetadata} + > + PUBLISH CHANGES + </LoadingButton> + )} </> ) } diff --git a/src/pages/settings/profile/style.module.scss b/src/pages/settings/profile/style.module.scss index 672e59c..6cdc029 100644 --- a/src/pages/settings/profile/style.module.scss +++ b/src/pages/settings/profile/style.module.scss @@ -1,9 +1,3 @@ -.container { - display: flex; - flex-direction: column; - gap: 25px; -} - .textField { width: 100%; } diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index c0542c5..9590695 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -12,7 +12,6 @@ import ListItemText from '@mui/material/ListItemText' import Switch from '@mui/material/Switch' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' -import { Container } from '../../../components/Container' import { useAppDispatch, useAppSelector, @@ -32,7 +31,6 @@ import { timeout } from '../../../utils' import styles from './style.module.scss' -import { Footer } from '../../../components/Footer/Footer' import { getRelayListForUser, NDKRelayList, @@ -246,7 +244,7 @@ export const RelaysPage = () => { } return ( - <Container className={styles.container}> + <> <Box className={styles.relayAddContainer}> <TextField label="Add new relay" @@ -291,8 +289,7 @@ export const RelaysPage = () => { ))} </Box> )} - <Footer /> - </Container> + </> ) } diff --git a/src/pages/settings/relays/style.module.scss b/src/pages/settings/relays/style.module.scss index 3db7760..df7eb31 100644 --- a/src/pages/settings/relays/style.module.scss +++ b/src/pages/settings/relays/style.module.scss @@ -1,107 +1,103 @@ @import '../../../styles/colors.scss'; -.container { - color: $text-color; +.relayURItextfield { + width: 100%; +} - .relayURItextfield { - width: 100%; +.relayAddContainer { + display: flex; + flex-direction: row; + gap: 10px; + width: 100%; + align-items: start; +} + +.sectionIcon { + font-size: 30px; +} + +.sectionTitle { + margin-top: 35px; + margin-bottom: 10px; + display: flex; + flex-direction: row; + gap: 5px; + font-size: 1.5rem; + line-height: 2rem; + font-weight: 600; +} + +.relaysContainer { + display: flex; + flex-direction: column; + gap: 15px; +} + +.relay { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + + .relayDivider { + margin-left: 10px; + margin-right: 10px; } - .relayAddContainer { + .leaveRelayContainer { display: flex; flex-direction: row; gap: 10px; - width: 100%; - align-items: start; + cursor: pointer; } - .sectionIcon { - font-size: 30px; + .showInfo { + cursor: pointer; } - .sectionTitle { - margin-top: 35px; - margin-bottom: 10px; + .showInfoIcon { + margin-right: 3px; + margin-bottom: auto; + vertical-align: middle; + } + + .relayInfoContainer { display: flex; - flex-direction: row; + flex-direction: column; gap: 5px; - font-size: 1.5rem; - line-height: 2rem; + text-wrap: wrap; + } + + .relayInfoTitle { font-weight: 600; } - .relaysContainer { - display: flex; - flex-direction: column; - gap: 15px; + .relayInfoSubTitle { + font-weight: 500; } - .relay { - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - - .relayDivider { - margin-left: 10px; - margin-right: 10px; - } - - .leaveRelayContainer { - display: flex; - flex-direction: row; - gap: 10px; - cursor: pointer; - } - - .showInfo { - cursor: pointer; - } - - .showInfoIcon { - margin-right: 3px; - margin-bottom: auto; - vertical-align: middle; - } - - .relayInfoContainer { - display: flex; - flex-direction: column; - gap: 5px; - text-wrap: wrap; - } - - .relayInfoTitle { - font-weight: 600; - } - - .relayInfoSubTitle { - font-weight: 500; - } - - .copyItem { - margin-left: 10px; - color: #34495e; - vertical-align: bottom; - cursor: pointer; - } - - .connectionStatus { - border-radius: 9999px; - width: 10px; - height: 10px; - margin-right: 5px; - margin-top: 2px; - } - - .connectionStatusConnected { - background-color: $relay-status-connected; - } - - .connectionStatusNotConnected { - background-color: $relay-status-notconnected; - } - - .connectionStatusUnknown { - background-color: $input-text-color; - } + .copyItem { + margin-left: 10px; + color: #34495e; + vertical-align: bottom; + cursor: pointer; } -} + + .connectionStatus { + border-radius: 9999px; + width: 10px; + height: 10px; + margin-right: 5px; + margin-top: 2px; + } + + .connectionStatusConnected { + background-color: $relay-status-connected; + } + + .connectionStatusNotConnected { + background-color: $relay-status-notconnected; + } + + .connectionStatusUnknown { + background-color: $input-text-color; + } +} \ No newline at end of file diff --git a/src/pages/settings/style.module.scss b/src/pages/settings/style.module.scss new file mode 100644 index 0000000..f2d45ef --- /dev/null +++ b/src/pages/settings/style.module.scss @@ -0,0 +1,43 @@ +.title { + margin: 0 0 15px 0; +} + +.main { + width: 100%; + display: grid; + grid-template-columns: 0.4fr 1.6fr; + position: relative; + grid-gap: 25px; + + >* { + width: 100%; + display: flex; + flex-direction: column; + grid-gap: 25px; + } +} + +.aside { + width: 100%; + background: white; + padding: 15px; + border-radius: 5px; + box-shadow: 0 0 4px 0 rgb(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + grid-gap: 15px; + + position: sticky; + top: 15px; +} + +.content { + width: 100%; + background: white; + padding: 15px; + border-radius: 5px; + box-shadow: 0 0 4px 0 rgb(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + grid-gap: 15px; +} \ No newline at end of file diff --git a/src/routes/util.tsx b/src/routes/util.tsx index e21dc13..99fe70f 100644 --- a/src/routes/util.tsx +++ b/src/routes/util.tsx @@ -7,7 +7,7 @@ import { ProfilePage } from '../pages/profile' import { NostrLoginPage } from '../pages/settings/nostrLogin' import { ProfileSettingsPage } from '../pages/settings/profile' import { RelaysPage } from '../pages/settings/relays' -import { SettingsPage } from '../pages/settings/Settings' +import { SettingsLayout } from '../pages/settings/Settings' import { SignPage } from '../pages/sign' import { VerifyPage } from '../pages/verify' import { PrivateRoute } from './PrivateRoute' @@ -96,32 +96,22 @@ export const privateRoutes = [ path: appPrivateRoutes.settings, element: ( <PrivateRoute> - <SettingsPage /> + <SettingsLayout /> </PrivateRoute> - ) - }, - { - path: appPrivateRoutes.profileSettings, - element: ( - <PrivateRoute> - <ProfileSettingsPage /> - </PrivateRoute> - ) - }, - { - path: appPrivateRoutes.relays, - element: ( - <PrivateRoute> - <RelaysPage /> - </PrivateRoute> - ) - }, - { - path: appPrivateRoutes.nostrLogin, - element: ( - <PrivateRoute> - <NostrLoginPage /> - </PrivateRoute> - ) + ), + children: [ + { + path: appPrivateRoutes.profileSettings, + element: <ProfileSettingsPage /> + }, + { + path: appPrivateRoutes.relays, + element: <RelaysPage /> + }, + { + path: appPrivateRoutes.nostrLogin, + element: <NostrLoginPage /> + } + ] } ] From afdc9449b19b33b3a4daf2cd84a2f7c32ad2b91b Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Mon, 10 Mar 2025 12:36:47 +0000 Subject: [PATCH 24/28] refactor(settings): remove base settings page, go directly to profile --- src/components/AppBar/AppBar.tsx | 2 +- src/pages/profile/index.tsx | 4 +- src/pages/settings/Settings.tsx | 6 +- src/pages/settings/profile/index.tsx | 131 ++++++++++----------------- src/routes/index.tsx | 6 +- src/routes/util.tsx | 5 +- 6 files changed, 57 insertions(+), 97 deletions(-) diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 68b04dd..e1c2220 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -181,7 +181,7 @@ export const AppBar = () => { onClick={() => { setAnchorElUser(null) - navigate(appPrivateRoutes.settings) + navigate(appPrivateRoutes.profileSettings) }} sx={{ justifyContent: 'center' diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 8e1e8c0..e5b29f6 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -13,7 +13,7 @@ import { Footer } from '../../components/Footer/Footer' import { LoadingSpinner } from '../../components/LoadingSpinner' import { useAppSelector } from '../../hooks/store' -import { getProfileSettingsRoute } from '../../routes' +import { appPrivateRoutes } from '../../routes' import { getProfileUsername, @@ -168,7 +168,7 @@ export const ProfilePage = () => { <Box className={styles.right}> {isUsersOwnProfile && ( <IconButton - onClick={() => navigate(getProfileSettingsRoute(pubkey))} + onClick={() => navigate(appPrivateRoutes.profileSettings)} > <EditIcon /> </IconButton> diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index f00eab4..4bd9735 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -3,7 +3,7 @@ import RouterIcon from '@mui/icons-material/Router' import { Button } from '@mui/material' import { useAppSelector } from '../../hooks/store' import { NavLink, Outlet, To } from 'react-router-dom' -import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes' +import { appPrivateRoutes } from '../../routes' import { Container } from '../../components/Container' import { Footer } from '../../components/Footer/Footer' import ExtensionIcon from '@mui/icons-material/Extension' @@ -50,7 +50,7 @@ const Item = (to: To, icon: ReactNode, label: string) => { } export const SettingsLayout = () => { - const { usersPubkey, loginMethod } = useAppSelector((state) => state.auth) + const { loginMethod } = useAppSelector((state) => state.auth) return ( <> @@ -60,7 +60,7 @@ export const SettingsLayout = () => { <div> <aside className={styles.aside}> {Item( - getProfileSettingsRoute(usersPubkey!), + appPrivateRoutes.profileSettings, <AccountCircleIcon />, 'Profile' )} diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 03db00f..5ca9000 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useState } from 'react' -import { useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { SmartToy } from '@mui/icons-material' @@ -38,10 +37,8 @@ import styles from './style.module.scss' export const ProfileSettingsPage = () => { const dispatch: Dispatch = useAppDispatch() - const { npub } = useParams() const { ndk, findMetadata, publish } = useNDKContext() - const [pubkey, setPubkey] = useState<string>() const [userProfile, setUserProfile] = useState<NDKUserProfile | null>(null) const userRobotImage = useAppSelector((state) => state.user.robotImage) @@ -52,27 +49,13 @@ export const ProfileSettingsPage = () => { ) const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) - const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') const robotSet = useRef(1) useEffect(() => { - if (npub) { - try { - const hexPubkey = nip19.decode(npub).data as string - setPubkey(hexPubkey) - - if (hexPubkey === usersPubkey) setIsUsersOwnProfile(true) - } catch (error) { - toast.error('Error occurred in decoding npub' + error) - } - } - }, [npub, usersPubkey]) - - useEffect(() => { - if (isUsersOwnProfile && currentUserProfile) { + if (usersPubkey && currentUserProfile) { setUserProfile(currentUserProfile) setIsLoading(false) @@ -80,8 +63,8 @@ export const ProfileSettingsPage = () => { return } - if (pubkey) { - findMetadata(pubkey) + if (usersPubkey) { + findMetadata(usersPubkey) .then((profile) => { setUserProfile(profile) }) @@ -92,7 +75,7 @@ export const ProfileSettingsPage = () => { setIsLoading(false) }) } - }, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata]) + }, [ndk, currentUserProfile, findMetadata, usersPubkey]) const editItem = ( key: keyof NDKUserProfile, @@ -110,7 +93,6 @@ export const ProfileSettingsPage = () => { multiline={multiline} rows={rows} className={styles.textField} - disabled={!isUsersOwnProfile} InputProps={inputProps} onChange={(event: React.ChangeEvent<HTMLInputElement>) => { const { value } = event.target @@ -167,7 +149,7 @@ export const ProfileSettingsPage = () => { content: serializedProfile, created_at: unixNow(), kind: kinds.Metadata, - pubkey: pubkey!, + pubkey: usersPubkey!, tags: [] } @@ -212,7 +194,7 @@ export const ProfileSettingsPage = () => { robotSet.current++ if (robotSet.current > 5) robotSet.current = 1 - const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) + const robotAvatarLink = getRoboHashPicture(usersPubkey!, robotSet.current) setUserProfile((prev) => ({ ...prev, @@ -241,13 +223,9 @@ export const ProfileSettingsPage = () => { * @returns robohash image url */ const getProfileImage = (profile: NDKUserProfile) => { - if (!isUsersOwnProfile) { - return profile.image || getRoboHashPicture(npub!) - } - // userRobotImage is used only when visiting own profile // while kind 0 picture is not set - return profile.image || userRobotImage || getRoboHashPicture(npub!) + return profile.image || userRobotImage || getRoboHashPicture(usersPubkey!) } return ( @@ -285,7 +263,7 @@ export const ProfileSettingsPage = () => { > <img onError={(event: React.SyntheticEvent<HTMLImageElement>) => { - event.currentTarget.src = getRoboHashPicture(npub!) + event.currentTarget.src = getRoboHashPicture(usersPubkey!) }} className={styles.img} src={getProfileImage(userProfile)} @@ -294,7 +272,7 @@ export const ProfileSettingsPage = () => { </ListItem> {editItem('image', 'Picture URL', undefined, undefined, { - endAdornment: isUsersOwnProfile ? robohashButton() : undefined + endAdornment: robohashButton() })} {editItem('name', 'Username')} @@ -303,61 +281,48 @@ export const ProfileSettingsPage = () => { {editItem('lud16', 'Lightning Address (lud16)')} {editItem('about', 'About', true, 4)} {editItem('website', 'Website')} - {isUsersOwnProfile && ( - <> - {usersPubkey && - copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} - - {loginMethod === LoginMethod.privateKey && - keys && - keys.private && - copyItem( - '••••••••••••••••••••••••••••••••••••••••••••••••••', - 'Private Key', - keys.private - )} - </> - )} - {isUsersOwnProfile && ( - <> - {loginMethod === LoginMethod.nostrLogin && - nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( - <ListItem - sx={{ marginTop: 1 }} - onClick={() => { - launchNostrLoginDialog('import') - }} - > - <TextField - label="Private Key (nostr-login)" - defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••" - size="small" - className={styles.textField} - disabled - type={'password'} - InputProps={{ - endAdornment: ( - <LaunchIcon className={styles.copyItem} /> - ) - }} - /> - </ListItem> - )} - </> - )} + {usersPubkey && + copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} + {loginMethod === LoginMethod.privateKey && + keys && + keys.private && + copyItem( + '••••••••••••••••••••••••••••••••••••••••••••••••••', + 'Private Key', + keys.private + )} + {loginMethod === LoginMethod.nostrLogin && + nostrLoginAuthMethod === NostrLoginAuthMethod.Local && ( + <ListItem + sx={{ marginTop: 1 }} + onClick={() => { + launchNostrLoginDialog('import') + }} + > + <TextField + label="Private Key (nostr-login)" + defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••" + size="small" + className={styles.textField} + disabled + type={'password'} + InputProps={{ + endAdornment: <LaunchIcon className={styles.copyItem} /> + }} + /> + </ListItem> + )} </div> )} </List> - {isUsersOwnProfile && ( - <LoadingButton - sx={{ maxWidth: '300px', alignSelf: 'center', width: '100%' }} - loading={savingProfileMetadata} - variant="contained" - onClick={handleSaveMetadata} - > - PUBLISH CHANGES - </LoadingButton> - )} + <LoadingButton + sx={{ maxWidth: '300px', alignSelf: 'center', width: '100%' }} + loading={savingProfileMetadata} + variant="contained" + onClick={handleSaveMetadata} + > + PUBLISH CHANGES + </LoadingButton> </> ) } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f514e78..f1bd004 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -4,8 +4,7 @@ export const appPrivateRoutes = { homePage: '/', create: '/create', sign: '/sign', - settings: '/settings', - profileSettings: '/settings/profile/:npub', + profileSettings: '/settings/profile', relays: '/settings/relays', nostrLogin: '/settings/nostrLogin' } @@ -23,6 +22,3 @@ export const appPublicRoutes = { export const getProfileRoute = (hexKey: string) => appPublicRoutes.profile.replace(':npub', hexToNpub(hexKey)) - -export const getProfileSettingsRoute = (hexKey: string) => - appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey)) diff --git a/src/routes/util.tsx b/src/routes/util.tsx index 99fe70f..e0356dd 100644 --- a/src/routes/util.tsx +++ b/src/routes/util.tsx @@ -37,7 +37,7 @@ export function recursiveRouteRenderer<T>( return routes.map((route, index) => renderConditionCallbackFn(route) ? ( <Route - key={`${route.path}${index}`} + key={route.path ? `${route.path}${index}` : index} path={route.path} element={route.element} > @@ -67,7 +67,7 @@ export const publicRoutes: PublicRouteProps[] = [ } ] -export const privateRoutes = [ +export const privateRoutes: CustomRouteProps<unknown>[] = [ { path: appPrivateRoutes.homePage, element: ( @@ -93,7 +93,6 @@ export const privateRoutes = [ ) }, { - path: appPrivateRoutes.settings, element: ( <PrivateRoute> <SettingsLayout /> From 4f5dcc03360d03b203261348b5809f0bf879ec03 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Tue, 11 Mar 2025 10:58:19 +0000 Subject: [PATCH 25/28] refactor(hooks): add comments to local storage hook --- src/hooks/useLocalStorage.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index ec6c9cb..a2a9532 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -6,6 +6,11 @@ import { setLocalStorageItem } from '../utils' +/** + * Subscribe to the Browser's storage event. Get the new value if any of the tabs changes it. + * @param callback - function to be called when the storage event is triggered + * @returns clean up function + */ const useLocalStorageSubscribe = (callback: () => void) => { window.addEventListener('storage', callback) return () => window.removeEventListener('storage', callback) @@ -28,8 +33,11 @@ export function useLocalStorage<T>( ) } + // https://react.dev/reference/react/useSyncExternalStore + // Returns the snapshot of the data and subscribes to the storage event const data = React.useSyncExternalStore(useLocalStorageSubscribe, getSnapshot) + // Takes the value or a function that returns the value and updates the local storage const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback( (v: React.SetStateAction<T>) => { try { From 493390bdc1152ea912a04485556ef5f9cb6a91b9 Mon Sep 17 00:00:00 2001 From: en <enes@nostrdev.com> Date: Tue, 11 Mar 2025 11:05:53 +0000 Subject: [PATCH 26/28] chore(deps): bump axios from 1.7.4 to 1.8.2 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index b439f36..019491c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@nostr-dev-kit/ndk-cache-dexie": "2.5.9", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", - "axios": "^1.7.4", + "axios": "^1.8.2", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", "dexie": "4.0.8", @@ -4051,9 +4051,9 @@ } }, "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", + "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", diff --git a/package.json b/package.json index 7e372f1..d4de123 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@nostr-dev-kit/ndk-cache-dexie": "2.5.9", "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", - "axios": "^1.7.4", + "axios": "^1.8.2", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", "dexie": "4.0.8", From dd2aa3dc40f8ef81798e1cfcd24f68ae5e0750e1 Mon Sep 17 00:00:00 2001 From: theborakompanioni <theborakompanioni+github@gmail.com> Date: Wed, 2 Apr 2025 19:37:27 +0200 Subject: [PATCH 27/28] chore(deps): run audit fix --- package-lock.json | 167 ++++++++++++++++++++++------------------------ 1 file changed, 81 insertions(+), 86 deletions(-) diff --git a/package-lock.json b/package-lock.json index 019491c..4c6f81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,12 +108,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -305,17 +307,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -330,37 +334,28 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.9.tgz", - "integrity": "sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.23.9", - "@babel/traverse": "^7.23.9", - "@babel/types": "^7.23.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -399,9 +394,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -410,14 +406,15 @@ } }, "node_modules/@babel/template": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", - "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -445,13 +442,13 @@ } }, "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1939,9 +1936,9 @@ } }, "node_modules/@octokit/endpoint": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.2.tgz", - "integrity": "sha512-XybpFv9Ms4hX5OCHMZqyODYqGTZ3H6K6Vva+M9LR7ib/xr1y1ZnlChYv9H680y77Vd/i/k+thXApeRASBQkzhA==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.3.tgz", + "integrity": "sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA==", "dev": true, "license": "MIT", "peer": true, @@ -1970,22 +1967,22 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.0.tgz", - "integrity": "sha512-ttpGck5AYWkwMkMazNCZMqxKqIq1fJBNxBfsFwwfyYKTf914jKkLF0POMS3YkPBwp5g1c2Y4L79gDz01GhSr1g==", + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@octokit/types": "^13.7.0" + "@octokit/types": "^13.10.0" }, "engines": { "node": ">= 18" @@ -2032,15 +2029,15 @@ } }, "node_modules/@octokit/request": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.0.tgz", - "integrity": "sha512-kXLfcxhC4ozCnAXy2ff+cSxpcF0A1UqxjvYMqNuPIeOAzJbVWQ+dy5G2fTylofB/gTbObT8O6JORab+5XtA1Kw==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.2.tgz", + "integrity": "sha512-dZl0ZHx6gOQGcffgm1/Sf6JfEpmh34v3Af2Uci02vzUYz6qEN6zepoRtmybWXIGXFIK8K9ylE3b+duCWqhArtg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@octokit/endpoint": "^10.0.0", - "@octokit/request-error": "^6.0.1", + "@octokit/endpoint": "^10.1.3", + "@octokit/request-error": "^6.1.7", "@octokit/types": "^13.6.2", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" @@ -2050,9 +2047,9 @@ } }, "node_modules/@octokit/request-error": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.6.tgz", - "integrity": "sha512-pqnVKYo/at0NuOjinrgcQYpEbv4snvP3bKMRqHaD9kIsk9u1LCpb2smHZi8/qJfgeNqLo5hNW4Z7FezNdEo0xg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.7.tgz", + "integrity": "sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==", "dev": true, "license": "MIT", "peer": true, @@ -2064,14 +2061,14 @@ } }, "node_modules/@octokit/types": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.7.0.tgz", - "integrity": "sha512-BXfRP+3P3IN6fd4uF3SniaHKOO4UXWBfkdR3vA8mIvaoO/wLjGN5qivUtW0QRitBHHMcfC41SLhNVYIZZE+wkA==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/openapi-types": "^24.2.0" } }, "node_modules/@pdf-lib/fontkit": { @@ -3862,6 +3859,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -4564,6 +4562,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -5021,6 +5020,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -5028,7 +5028,8 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true }, "node_modules/color-support": { "version": "1.1.3", @@ -6724,9 +6725,9 @@ "dev": true }, "node_modules/elliptic": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.0.tgz", - "integrity": "sha512-dpwoQcLc/2WLQvJvLRHKZ+f9FgOdjnq11rurqwekGQygGPsYSK29OMMD2WalatiqQ+XGFDglTNixpPfI+lpaAA==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", "dev": true, "license": "MIT", "dependencies": { @@ -6939,6 +6940,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, "engines": { "node": ">=0.8.0" } @@ -8278,6 +8280,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, "engines": { "node": ">=4" } @@ -13879,7 +13882,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -16582,6 +16584,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -16850,14 +16853,6 @@ "node": ">=0.6.0" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, "node_modules/to-readable-stream": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-2.1.0.tgz", @@ -17310,9 +17305,9 @@ } }, "node_modules/vite": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", - "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "version": "5.4.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz", + "integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==", "dev": true, "license": "MIT", "dependencies": { From 13044d6b39e464b6e7de6a825b9a15858a652e4a Mon Sep 17 00:00:00 2001 From: tbk <theborakompanioni+nostrdev@gmail.com> Date: Thu, 3 Apr 2025 11:40:04 +0000 Subject: [PATCH 28/28] feat: enable pwa (#324) This PR addresses 2 of 3 tasks from #93. - [x] It should be possible to download SIGit as a PWA on a device homescreen. - [x] This app should self-update Co-authored-by: theborakompanioni <theborakompanioni+github@gmail.com> Co-authored-by: b <b@4j.cx> Reviewed-on: https://git.nostrdev.com/sigit/sigit.io/pulls/324 Reviewed-by: enes <enes@noreply.git.nostrdev.com> Co-authored-by: tbk <theborakompanioni+nostrdev@gmail.com> Co-committed-by: tbk <theborakompanioni+nostrdev@gmail.com> --- index.html | 1 + public/app.webmanifest | 58 +++++++++++++++++++++++++++++++++++++ public/favicon-128x128.png | Bin 0 -> 3371 bytes public/favicon-144x144.png | Bin 0 -> 3756 bytes public/favicon-192x192.png | Bin 0 -> 5352 bytes public/favicon-256x256.png | Bin 0 -> 8203 bytes public/favicon-384x384.png | Bin 0 -> 13215 bytes public/favicon-512x512.png | Bin 0 -> 15473 bytes public/favicon-64x64.png | Bin 0 -> 1552 bytes public/favicon-72x72.png | Bin 0 -> 1732 bytes public/favicon-96x96.png | Bin 0 -> 2377 bytes public/favicon.svg | 25 ++++++++++++++++ 12 files changed, 84 insertions(+) create mode 100644 public/app.webmanifest create mode 100644 public/favicon-128x128.png create mode 100644 public/favicon-144x144.png create mode 100644 public/favicon-192x192.png create mode 100644 public/favicon-256x256.png create mode 100644 public/favicon-384x384.png create mode 100644 public/favicon-512x512.png create mode 100644 public/favicon-64x64.png create mode 100644 public/favicon-72x72.png create mode 100644 public/favicon-96x96.png create mode 100644 public/favicon.svg diff --git a/index.html b/index.html index 501fda6..461cf18 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ <meta charset="UTF-8" /> <link rel="icon" type="image/png" href="/favicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <link rel="manifest" href="/app.webmanifest" /> <title>SIGit</title> </head> <body> diff --git a/public/app.webmanifest b/public/app.webmanifest new file mode 100644 index 0000000..c0b073f --- /dev/null +++ b/public/app.webmanifest @@ -0,0 +1,58 @@ +{ + "short_name": "SIGit", + "name": "SIGit", + "description": "A decentralised document signing tool", + "icons": [ + { + "src": "favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "favicon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "favicon-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "favicon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "favicon-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "favicon-128x128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "favicon-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "favicon-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "favicon-64x64.png", + "sizes": "64x64", + "type": "image/png" + } + ], + "start_url": "/", + "display_override": ["minimal-ui", "standalone"], + "display": "minimal-ui", + "orientation": "any", + "theme_color": "#7d54a3", + "background_color": "#ffffff" +} diff --git a/public/favicon-128x128.png b/public/favicon-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..ada1e351e4d98617f75734c45b18e83e8540bed2 GIT binary patch literal 3371 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 BP~rdp literal 0 HcmV?d00001 diff --git a/public/favicon-144x144.png b/public/favicon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..d226bc3dca64b101a85c3487abcb4c588a8e872b GIT binary patch literal 3756 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 WVYJ<*qtn^|0000<MNUMnLSTY;YVNH7 literal 0 HcmV?d00001 diff --git a/public/favicon-192x192.png b/public/favicon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..d5ce7918018ace53cedc6a38a32b3c6ef84b179e GIT binary patch literal 5352 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 literal 0 HcmV?d00001 diff --git a/public/favicon-256x256.png b/public/favicon-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..b6a340f82fc07c22b72fc5e0b67e64a2e2833df6 GIT binary patch literal 8203 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 RSNIe>pr>uDRi%N5{6ENAEwlgt literal 0 HcmV?d00001 diff --git a/public/favicon-384x384.png b/public/favicon-384x384.png new file mode 100644 index 0000000000000000000000000000000000000000..cc9872ee5958e9013a31c7be7d32b123ca3bd304 GIT binary patch literal 13215 zcmbVTg;!Kvw7(49-Hjq24I-Tq5)wKvARsLuDM)vh(jhI~Ap+7cgGfk+z|bkp&|UBP z-hc3B)~s1`X5X`aJI~(d+#B^uQ<)Ht4i5kTLRA$-C;*@Z{`=ryfg^>zKj7dOhV^re z=K%0M4*%8+6a0<l0#%kroB7QX0RCXHP=RUyfcFys2n+>)Yj7xV3jo{%0AR-y03^}? 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�q@EB*F!X9GFCMnL0!Z=nXO)^8vNQT-=~;? zDnSfS_up20a*f_T9b>u->f8((E|0Dp558GYHMgFZcz5d(<|z--k)GZUY%%5ahnt-{ zEZdc<bQ^A=;4!M_qK!n&PO5odbwJHO_Os4nWXl#odm!r}Ms#55Y`dt2@$Q|$GjHM; znZAm?m$zfhc}tz4lE5hAS6>xfwN1256hU|HC)9ysA1q>Le^B9Va<rwF*86gU=xb9Y z4L`UW3za9b6%*F+n7bUnki-*%AdO)*9#oQVtrIw1uw&rg()hs$ljmeo6>pjSUPVB} zhO|L^t=C|kv~KYI>$DMQLa}fF2UT2b4reh^XDedBm{87)h(F)D2{T>lMlvHA_C<9x zzd+)a?}ci_U9e+EQu^`1Ba7roytT1b)2X=yBH=Z_mFYRBA;x_44IxvK7kENq%kzV} z6d>Ugx_LN?m@3nrX6p2FAwDPU>InNz?Vt>^T~1?To`M`KzYXb?HCvhSLht40pQNq; zNb)L)s~QJu?YXwU&d%_<B{!e-BFT^>4wBD1ug_PEdQE=r&la-ZM8vD|w^%y)@tjdt z10*)e9Wr-W)#*AL4?+7DHQ0yjv$_le*V8!jyC@H{Tj_~e*Mmwk>c->={#=myTU7{c zL2E|XSI=6)&_%2e!CfUo(S+@jk=fz7I5F;^R>qv5qYo}V1?&xNKAX#*&BgDbEr4Qa zWS4EQ8E5_hs`EOK@qLfGoJTJGY|>Ui$02=CQ4>E#E5CP}@RMdq=(f6r>uOcVPWya- zD(D<x*FC3W^u_2V0(Jb$4cImKJ(`Fq%Ik$gqC+Xv2>M9t%mme4+21Peuh+d}@WF;& z6R_lAW4NDo5faSaiPiV;I56uO#_|#018)LGm)6}C;p#;_GTARd*Y_~SV(Pgg^$0)= zQkn9cJmzb)?ECdM<*<VrPz;UBFrpY7>@=_t$v!`AU=3{_L=v`9BO7QIzy}%D%5x5+ zKXKyMS9(7~A0px&x^ol#+PSZ9?#uT3*t1-Mr_(v#r&@Tkk=Z3kZ}z4VAW3lhxMdPB z&RZyyX>LYdqd~azE3d?CfuV(Nk#Gxdiqc}zWFo48QEQ^U%21^>N#te;{o}mqpwn!z zzDSjKedTpC*~N>&FTwoXHW*(#4Mm4r+b)Ez<-Z8G3w~JVH#m#s1QZ{S5T$$T9>!)r z-PWo6$%FodHNKM<y>LjX$pWi0CNPus>(4=%dX0}5E>5C-%?SXmrn!}PoZ#-83jIV4 v1F1Q;*V^tq9s&E_*#DnrN)!tP?qwslb&b@*d2qnTVgOYoO~q<?)8PLB*lkmb literal 0 HcmV?d00001 diff --git a/public/favicon-512x512.png b/public/favicon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..fe48fe3c30090a704f6b7da417fdcf25f7579286 GIT binary patch literal 15473 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 Z5%?CZs2j=l)e;92^-x2h<i2_E{{aToAhZAg literal 0 HcmV?d00001 diff --git a/public/favicon-64x64.png b/public/favicon-64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..d59e0164438e87f3b237a36e092c3ec540088d63 GIT binary patch literal 1552 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 C`mi7X literal 0 HcmV?d00001 diff --git a/public/favicon-72x72.png b/public/favicon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..168bb20fe7434adec039ed7a2c1e890e8d042d18 GIT binary patch literal 1732 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 agZ>1FjMM4_$`Sei0000<MNUMnLSTYji4S-H literal 0 HcmV?d00001 diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..5267e602a23ecb0f31e18c28bc96e71c37d6f090 GIT binary patch literal 2377 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 literal 0 HcmV?d00001 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..144306d --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 282.61 282.61"> + <defs> + <style> + .cls-1 { + fill: #47b17d; + } + + .cls-2 { + fill: #4c82a3; + } + + .cls-3 { + fill: #7d54a3; + } + </style> + </defs> + <g id="Layer_1-2" data-name="Layer 1" transform="translate(0, 13.775)"> + <g> + <path class="cls-2" d="M181.53,115.06h0c-9.4-36.67-56.77-24.79-121.09-12.57C-3.54,114.64-25.35,19.85,37.72,3.62,46.91,1.26,56.55,0,66.47,0c63.55,0,115.06,51.51,115.06,115.06Z"/> + <path class="cls-1" d="M100,140h0c9.4,36.67,56.77,24.79,121.09,12.57,63.98-12.16,85.79,82.64,22.72,98.86-9.19,2.36-18.83,3.62-28.76,3.62-63.55,0-115.06-51.51-115.06-115.06Z"/> + <circle class="cls-3" cx="140.77" cy="127.53" r="24.88"/> + </g> + </g> +</svg> \ No newline at end of file