From f8a4c99ebc970f82a2b5ef516c18cc135bb956e2 Mon Sep 17 00:00:00 2001 From: Milad Raeisi Date: Wed, 18 Sep 2024 16:53:14 +0400 Subject: [PATCH] Add profile editing feature --- .../settings/account/account.component.html | 168 ------------------ .../settings/account/account.component.ts | 71 -------- .../settings/profile/profile.component.html | 103 +++++++++++ .../settings/profile/profile.component.ts | 126 +++++++++++++ .../settings/settings.component.html | 6 +- .../components/settings/settings.component.ts | 8 +- src/app/services/metadata.service.ts | 22 +++ src/app/services/relay.service.ts | 42 ++++- src/app/services/signer.service.ts | 23 ++- 9 files changed, 321 insertions(+), 248 deletions(-) delete mode 100644 src/app/components/settings/account/account.component.html delete mode 100644 src/app/components/settings/account/account.component.ts create mode 100644 src/app/components/settings/profile/profile.component.html create mode 100644 src/app/components/settings/profile/profile.component.ts diff --git a/src/app/components/settings/account/account.component.html b/src/app/components/settings/account/account.component.html deleted file mode 100644 index 55cba56..0000000 --- a/src/app/components/settings/account/account.component.html +++ /dev/null @@ -1,168 +0,0 @@ -
- -
- -
-
Profile
-
- Following information is publicly displayed, be careful! -
-
-
- -
- - Name - - - -
- -
- - Username -
angortheme.com/
- -
-
- -
- - Title - - - -
- -
- - Company - - - -
- -
- - About - - -
- Brief description for your profile. Basic HTML and Emoji are - allowed. -
-
-
- - -
- - -
-
Personal Information
-
- Communication details in case we want to connect with you. These - will be kept private. -
-
-
- -
- - Email - - - -
- -
- - Phone - - - -
- -
- - Country - - - United States - Canada - Mexico - France - Germany - Italy - - -
- -
- - Language - - - English - French - Spanish - German - Italian - - -
-
- - -
- - -
- - -
-
-
diff --git a/src/app/components/settings/account/account.component.ts b/src/app/components/settings/account/account.component.ts deleted file mode 100644 index 6ba8b01..0000000 --- a/src/app/components/settings/account/account.component.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { TextFieldModule } from '@angular/cdk/text-field'; -import { - ChangeDetectionStrategy, - Component, - OnInit, - ViewEncapsulation, -} from '@angular/core'; -import { - FormsModule, - ReactiveFormsModule, - UntypedFormBuilder, - UntypedFormGroup, - Validators, -} from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatOptionModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; - -@Component({ - selector: 'settings-account', - templateUrl: './account.component.html', - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [ - FormsModule, - ReactiveFormsModule, - MatFormFieldModule, - MatIconModule, - MatInputModule, - TextFieldModule, - MatSelectModule, - MatOptionModule, - MatButtonModule, - ], -}) -export class SettingsAccountComponent implements OnInit { - accountForm: UntypedFormGroup; - - /** - * Constructor - */ - constructor(private _formBuilder: UntypedFormBuilder) {} - - // ----------------------------------------------------------------------------------------------------- - // @ Lifecycle hooks - // ----------------------------------------------------------------------------------------------------- - - /** - * On init - */ - ngOnInit(): void { - // Create the form - this.accountForm = this._formBuilder.group({ - name: ['Display Name'], - username: ['brianh'], - title: ['Senior Frontend Developer'], - company: ['YXZ Software'], - about: [ - "Hey! This is Brian; husband, father and gamer. I'm mostly passionate about bleeding edge tech and chocolate! 🍫", - ], - email: ['hughes.brian@mail.com', Validators.email], - phone: ['121-490-33-12'], - country: ['usa'], - language: ['english'], - }); - } -} diff --git a/src/app/components/settings/profile/profile.component.html b/src/app/components/settings/profile/profile.component.html new file mode 100644 index 0000000..421acec --- /dev/null +++ b/src/app/components/settings/profile/profile.component.html @@ -0,0 +1,103 @@ +
+ +
+ +
+
+ Following information is publicly displayed, be careful! +
+
+ +
+ +
+ + Name + + + +
+ + +
+ + Username + + +
+ + +
+ + Display Name + + +
+ + +
+ + Website + + +
+ + +
+ + About + + +
+ Brief description for your profile. Basic HTML and Emoji are allowed. +
+
+ + +
+ + Profile Picture URL + + +
+ + +
+ + Banner URL + + +
+ + +
+ + LUD06 + + +
+ + +
+ + LUD16 + + +
+ + +
+ + NIP05 + + +
+
+ + +
+ + +
+
+
diff --git a/src/app/components/settings/profile/profile.component.ts b/src/app/components/settings/profile/profile.component.ts new file mode 100644 index 0000000..ac3fc9c --- /dev/null +++ b/src/app/components/settings/profile/profile.component.ts @@ -0,0 +1,126 @@ +import { TextFieldModule } from '@angular/cdk/text-field'; +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + ViewEncapsulation, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatOptionModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { Router } from '@angular/router'; +import { hexToBytes } from '@noble/hashes/utils'; +import { MetadataService } from 'app/services/metadata.service'; +import { RelayService } from 'app/services/relay.service'; +import { SignerService } from 'app/services/signer.service'; +import { finalizeEvent, NostrEvent, UnsignedEvent } from 'nostr-tools'; + +@Component({ + selector: 'settings-profile', + templateUrl: './profile.component.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + TextFieldModule, + MatSelectModule, + MatOptionModule, + MatButtonModule, + CommonModule + ], +}) +export class SettingsProfileComponent implements OnInit { + profileForm: FormGroup; + content: string; + + constructor( + private fb: FormBuilder, + private signerService: SignerService, + private metadataService: MetadataService, + private relayService: RelayService, + private router: Router + ) {} + + ngOnInit(): void { + this.profileForm = this.fb.group({ + name: ['', Validators.required], + username: [''], + displayName: [''], + website: [''], + about: [''], + picture: [''], + banner: [''], + lud06: [''], + lud16: ['', Validators.pattern("^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,4}$")], + nip05: ['', Validators.pattern("^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$")] + }); + + this.setValues(); + } + + async setValues() { + let kind0 = await this.metadataService.getUserMetadata(this.signerService.getPublicKey()); + if (kind0) { + this.profileForm.setValue({ + name: kind0.name || '', + username: kind0.username || '', + displayName: kind0.displayName || '', + website: kind0.website || '', + about: kind0.about || '', + picture: kind0.picture || '', + banner: kind0.banner || '', + lud06: kind0.lud06 || '', + lud16: kind0.lud16 || '', + nip05: kind0.nip05 || '', + }); + } + } + + + onSubmit() { + if (this.profileForm.valid) { + this.submit(); + } else { + console.error('Form is invalid'); + } + } + + async submit() { + const profileData = this.profileForm.value; + this.content = JSON.stringify(profileData); + const privateKey = await this.signerService.getSecretKey("1"); + console.log(privateKey); + let unsignedEvent: UnsignedEvent = this.signerService.getUnsignedEvent(0, [], this.content); + console.log(unsignedEvent); + let signedEvent: NostrEvent; + + if (privateKey !== null) { + const privateKeyBytes = hexToBytes(privateKey); + signedEvent = finalizeEvent(unsignedEvent, privateKeyBytes); + } else { + console.log('Using extension to sign event'); + signedEvent = await this.signerService.signEventWithExtension(unsignedEvent); + } + + this.relayService.publishEventToRelays(signedEvent); + console.log("Profile Updated!"); + this.router.navigate([`/profile`]); + } + +} diff --git a/src/app/components/settings/settings.component.html b/src/app/components/settings/settings.component.html index 10fb3b3..a686ba5 100644 --- a/src/app/components/settings/settings.component.html +++ b/src/app/components/settings/settings.component.html @@ -81,9 +81,9 @@ @case ('indexer') { } - - @case ('account') { - + + @case ('profile') { + } @case ('security') { diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 68c8121..4a81a6f 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -13,7 +13,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatDrawer, MatSidenavModule } from '@angular/material/sidenav'; import { AngorMediaWatcherService } from '@angor/services/media-watcher'; import { Subject, takeUntil } from 'rxjs'; -import { SettingsAccountComponent } from './account/account.component'; +import { SettingsProfileComponent } from './profile/profile.component'; import { SettingsNotificationsComponent } from './notifications/notifications.component'; import { SettingsSecurityComponent } from './security/security.component'; import { SettingsRelayComponent } from './relay/relay.component'; @@ -31,7 +31,7 @@ import { SettingsIndexerComponent } from "./indexer/indexer.component"; MatButtonModule, MatIconModule, NgClass, - SettingsAccountComponent, + SettingsProfileComponent, SettingsSecurityComponent, SettingsNotificationsComponent, SettingsRelayComponent, @@ -63,9 +63,9 @@ export class SettingsComponent implements OnInit, OnDestroy { description: 'Add, remove, and manage your indexers, including setting the primary indexer.', }, { - id: 'account', + id: 'profile', icon: 'heroicons_outline:user', - title: 'Account', + title: 'Profile', description: 'Update your personal profile, manage your account details, and modify your private information.', }, { diff --git a/src/app/services/metadata.service.ts b/src/app/services/metadata.service.ts index 5da3fc5..f9e7a75 100644 --- a/src/app/services/metadata.service.ts +++ b/src/app/services/metadata.service.ts @@ -176,4 +176,26 @@ export class MetadataService { storedUsers.forEach(user => this.enqueueRequest(user.pubkey)); } + + + async getUserMetadata(pubkey: string): Promise { + try { + const cachedMetadata = await this.indexedDBService.getUserMetadata(pubkey); + if (cachedMetadata) { + return cachedMetadata; + } + + const liveMetadata = await this.fetchMetadataRealtime(pubkey); + if (liveMetadata) { + await this.indexedDBService.saveUserMetadata(pubkey, liveMetadata); + return liveMetadata; + } + + return null; + } catch (error) { + console.error(`Error fetching metadata for user ${pubkey}:`, error); + return null; + } + } + } diff --git a/src/app/services/relay.service.ts b/src/app/services/relay.service.ts index bb98ff2..91ac734 100644 --- a/src/app/services/relay.service.ts +++ b/src/app/services/relay.service.ts @@ -51,7 +51,7 @@ export class RelayService { private connectToRelay(relay: { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }): void { if (relay.connected) { - return; + return; } relay.ws = new WebSocket(relay.url); @@ -156,6 +156,46 @@ export class RelayService { }); } + + async publishEventToRelays(event: NostrEvent): Promise { + const pool = this.getPool(); + const connectedRelays = this.getConnectedRelays(); + + if (connectedRelays.length === 0) { + throw new Error('No connected relays'); + } + + const publishPromises = connectedRelays.map(async (relayUrl) => { + try { + await pool.publish([relayUrl], event); + console.log(`Event published to relay: ${relayUrl}`); + this.eventSubject.next(event); // Emit the event to subscribers + return event; + } catch (error) { + console.error(`Failed to publish event to relay: ${relayUrl}`, error); + throw error; + } + }); + + try { + await Promise.any(publishPromises); + return event; + } catch (aggregateError) { + console.error('Failed to publish event: AggregateError', aggregateError); + this.handlePublishFailure(aggregateError); + throw aggregateError; + } + } + + private handlePublishFailure(error: unknown): void { + if (error instanceof AggregateError) { + console.error('All relays failed to publish the event. Retrying...'); + } else { + console.error('An unexpected error occurred:', error); + } + } + + public addRelay(url: string, accessType: string = 'read-write'): void { if (!this.relays.some(relay => relay.url === url)) { const newRelay = { url, connected: false, retries: 0, retryTimeout: null, accessType, ws: undefined }; diff --git a/src/app/services/signer.service.ts b/src/app/services/signer.service.ts index d9ff46f..b606337 100644 --- a/src/app/services/signer.service.ts +++ b/src/app/services/signer.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@angular/core'; -import { UnsignedEvent, nip19, getPublicKey, nip04, Event, generateSecretKey } from 'nostr-tools'; +import { UnsignedEvent, nip19, getPublicKey, nip04, Event, generateSecretKey, finalizeEvent } from 'nostr-tools'; import { Buffer } from 'buffer'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; import { SecurityService } from './security.service'; +import { hexToBytes } from '@noble/hashes/utils'; @Injectable({ providedIn: 'root' @@ -246,5 +247,25 @@ export class SignerService { return "*Failed to Decrypted Content*"; } } + getUnsignedEvent(kind: number, tags: string[][], content: string) { + const eventUnsigned: UnsignedEvent = { + kind: kind, + pubkey: this.getPublicKey(), + tags: tags, + content: content, + created_at: Math.floor(Date.now() / 1000), + } + return eventUnsigned + } + + getSignedEvent(eventUnsigned: UnsignedEvent, privateKey: string): Event { + // Convert the private key from hex string to Uint8Array + const privateKeyBytes = hexToBytes(privateKey); + + // finalizing and signing the event in one step + const signedEvent: Event = finalizeEvent(eventUnsigned, privateKeyBytes); + + return signedEvent; + } }