profile-edit.component.ts raw

   1  import { Component, inject, OnInit } from '@angular/core';
   2  import { FormsModule } from '@angular/forms';
   3  import { Router } from '@angular/router';
   4  import {
   5    FALLBACK_PROFILE_RELAYS,
   6    NavComponent,
   7    NostrHelper,
   8    ProfileMetadataService,
   9    RelayListService,
  10    StorageService,
  11    ToastComponent,
  12    publishToRelaysWithAuth,
  13  } from '@common';
  14  import { SimplePool } from 'nostr-tools/pool';
  15  import { finalizeEvent } from 'nostr-tools';
  16  import { hexToBytes } from '@noble/hashes/utils';
  17  
  18  interface ProfileFormData {
  19    name: string;
  20    display_name: string;
  21    picture: string;
  22    banner: string;
  23    website: string;
  24    about: string;
  25    nip05: string;
  26    lud16: string;
  27    lnurl: string;
  28  }
  29  
  30  @Component({
  31    selector: 'app-profile-edit',
  32    templateUrl: './profile-edit.component.html',
  33    styleUrl: './profile-edit.component.scss',
  34    imports: [FormsModule, ToastComponent],
  35  })
  36  export class ProfileEditComponent extends NavComponent implements OnInit {
  37    readonly #storage = inject(StorageService);
  38    readonly #router = inject(Router);
  39    readonly #profileMetadata = inject(ProfileMetadataService);
  40    readonly #relayList = inject(RelayListService);
  41  
  42    profile: ProfileFormData = {
  43      name: '',
  44      display_name: '',
  45      picture: '',
  46      banner: '',
  47      website: '',
  48      about: '',
  49      nip05: '',
  50      lud16: '',
  51      lnurl: '',
  52    };
  53  
  54    // Store original event content to preserve extra fields
  55    #originalContent: Record<string, unknown> = {};
  56    #originalTags: string[][] = [];
  57  
  58    loading = true;
  59    saving = false;
  60    alertMessage: string | undefined;
  61    #privkey: string | undefined;
  62    #pubkey: string | undefined;
  63  
  64    async ngOnInit() {
  65      await this.#loadProfile();
  66    }
  67  
  68    async #loadProfile() {
  69      try {
  70        const selectedIdentityId =
  71          this.#storage.getBrowserSessionHandler().browserSessionData
  72            ?.selectedIdentityId ?? null;
  73  
  74        const identity = this.#storage
  75          .getBrowserSessionHandler()
  76          .browserSessionData?.identities.find(
  77            (x) => x.id === selectedIdentityId
  78          );
  79  
  80        if (!identity) {
  81          this.loading = false;
  82          return;
  83        }
  84  
  85        this.#privkey = identity.privkey;
  86        this.#pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
  87  
  88        // Initialize services
  89        await this.#profileMetadata.initialize();
  90  
  91        // Try to get cached profile first
  92        const cachedProfile = this.#profileMetadata.getCachedProfile(this.#pubkey);
  93        if (cachedProfile) {
  94          this.profile = {
  95            name: cachedProfile.name || '',
  96            display_name: cachedProfile.display_name || cachedProfile.displayName || '',
  97            picture: cachedProfile.picture || '',
  98            banner: cachedProfile.banner || '',
  99            website: cachedProfile.website || '',
 100            about: cachedProfile.about || '',
 101            nip05: cachedProfile.nip05 || '',
 102            lud16: cachedProfile.lud16 || '',
 103            lnurl: cachedProfile.lud06 || '',
 104          };
 105        }
 106  
 107        // Fetch the actual kind 0 event to get original content and tags
 108        await this.#fetchOriginalEvent();
 109  
 110        this.loading = false;
 111      } catch (error) {
 112        console.error('Failed to load profile:', error);
 113        this.loading = false;
 114      }
 115    }
 116  
 117    async #fetchOriginalEvent() {
 118      if (!this.#pubkey) return;
 119  
 120      const pool = new SimplePool();
 121      try {
 122        const events = await this.#queryWithTimeout(
 123          pool,
 124          FALLBACK_PROFILE_RELAYS,
 125          [{ kinds: [0], authors: [this.#pubkey] }],
 126          10000
 127        );
 128  
 129        if (events.length > 0) {
 130          // Get the most recent event
 131          const latestEvent = events.reduce((latest, event) =>
 132            event.created_at > latest.created_at ? event : latest
 133          );
 134  
 135          // Store original tags (excluding the ones we'll update)
 136          this.#originalTags = latestEvent.tags.filter(
 137            (tag: string[]) =>
 138              tag[0] !== 'name' &&
 139              tag[0] !== 'display_name' &&
 140              tag[0] !== 'picture' &&
 141              tag[0] !== 'banner' &&
 142              tag[0] !== 'website' &&
 143              tag[0] !== 'about' &&
 144              tag[0] !== 'nip05' &&
 145              tag[0] !== 'lud16' &&
 146              tag[0] !== 'client'
 147          );
 148  
 149          // Parse and store original content
 150          try {
 151            this.#originalContent = JSON.parse(latestEvent.content);
 152  
 153            // Update form with values from event content
 154            this.profile = {
 155              name: (this.#originalContent['name'] as string) || '',
 156              display_name:
 157                (this.#originalContent['display_name'] as string) ||
 158                (this.#originalContent['displayName'] as string) ||
 159                '',
 160              picture: (this.#originalContent['picture'] as string) || '',
 161              banner: (this.#originalContent['banner'] as string) || '',
 162              website: (this.#originalContent['website'] as string) || '',
 163              about: (this.#originalContent['about'] as string) || '',
 164              nip05: (this.#originalContent['nip05'] as string) || '',
 165              lud16: (this.#originalContent['lud16'] as string) || '',
 166              lnurl: (this.#originalContent['lnurl'] as string) || '',
 167            };
 168          } catch {
 169            console.error('Failed to parse profile content');
 170          }
 171        }
 172      } finally {
 173        pool.close(FALLBACK_PROFILE_RELAYS);
 174      }
 175    }
 176  
 177    // eslint-disable-next-line @typescript-eslint/no-explicit-any
 178    async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
 179      return new Promise((resolve) => {
 180        // eslint-disable-next-line @typescript-eslint/no-explicit-any
 181        const events: any[] = [];
 182        let settled = false;
 183  
 184        const timeout = setTimeout(() => {
 185          if (!settled) {
 186            settled = true;
 187            resolve(events);
 188          }
 189        }, timeoutMs);
 190  
 191        const sub = pool.subscribeMany(relays, filters, {
 192          onevent(event) {
 193            events.push(event);
 194          },
 195          oneose() {
 196            if (!settled) {
 197              settled = true;
 198              clearTimeout(timeout);
 199              sub.close();
 200              resolve(events);
 201            }
 202          },
 203        });
 204      });
 205    }
 206  
 207    async onClickSave() {
 208      if (this.saving || !this.#privkey || !this.#pubkey) return;
 209  
 210      this.saving = true;
 211      this.alertMessage = undefined;
 212  
 213      try {
 214        // Build the content JSON, preserving extra fields
 215        const content: Record<string, unknown> = { ...this.#originalContent };
 216  
 217        // Update with form values
 218        content['name'] = this.profile.name;
 219        content['display_name'] = this.profile.display_name;
 220        content['displayName'] = this.profile.display_name; // Some clients use this
 221        content['picture'] = this.profile.picture;
 222        content['banner'] = this.profile.banner;
 223        content['website'] = this.profile.website;
 224        content['about'] = this.profile.about;
 225        content['nip05'] = this.profile.nip05;
 226        content['lud16'] = this.profile.lud16;
 227        if (this.profile.lnurl) {
 228          content['lnurl'] = this.profile.lnurl;
 229        }
 230        content['pubkey'] = this.#pubkey;
 231  
 232        // Build tags array, preserving extra tags
 233        const tags: string[][] = [...this.#originalTags];
 234  
 235        // Add standard tags
 236        if (this.profile.name) tags.push(['name', this.profile.name]);
 237        if (this.profile.display_name) tags.push(['display_name', this.profile.display_name]);
 238        if (this.profile.picture) tags.push(['picture', this.profile.picture]);
 239        if (this.profile.banner) tags.push(['banner', this.profile.banner]);
 240        if (this.profile.website) tags.push(['website', this.profile.website]);
 241        if (this.profile.about) tags.push(['about', this.profile.about]);
 242        if (this.profile.nip05) tags.push(['nip05', this.profile.nip05]);
 243        if (this.profile.lud16) tags.push(['lud16', this.profile.lud16]);
 244  
 245        // Add alt tag if not present
 246        if (!tags.some(t => t[0] === 'alt')) {
 247          tags.push(['alt', `User profile for ${this.profile.name || this.profile.display_name || 'user'}`]);
 248        }
 249  
 250        // Always add client tag
 251        tags.push(['client', 'smesh-signer']);
 252  
 253        // Create the unsigned event
 254        const unsignedEvent = {
 255          kind: 0,
 256          created_at: Math.floor(Date.now() / 1000),
 257          tags,
 258          content: JSON.stringify(content),
 259        };
 260  
 261        // Sign the event
 262        const privkeyBytes = hexToBytes(this.#privkey);
 263        const signedEvent = finalizeEvent(unsignedEvent, privkeyBytes);
 264  
 265        // Get write relays from NIP-65 or use fallback
 266        await this.#relayList.initialize();
 267        const writeRelays = await this.#relayList.fetchRelayList(this.#pubkey);
 268        let relayUrls: string[];
 269  
 270        if (writeRelays.length > 0) {
 271          // Filter to write relays only
 272          relayUrls = writeRelays
 273            .filter(r => r.write)
 274            .map(r => r.url);
 275  
 276          // If no write relays found, use all relays
 277          if (relayUrls.length === 0) {
 278            relayUrls = writeRelays.map(r => r.url);
 279          }
 280        } else {
 281          // Use fallback relays
 282          relayUrls = FALLBACK_PROFILE_RELAYS;
 283        }
 284  
 285        // Publish to relays with NIP-42 authentication support
 286        const results = await publishToRelaysWithAuth(
 287          relayUrls,
 288          signedEvent,
 289          this.#privkey
 290        );
 291  
 292        // Count successes
 293        const successes = results.filter(r => r.success);
 294        const failures = results.filter(r => !r.success);
 295  
 296        if (failures.length > 0) {
 297          console.log('Some relays failed:', failures.map(f => `${f.relay}: ${f.message}`));
 298        }
 299  
 300        if (successes.length === 0) {
 301          throw new Error('Failed to publish to any relay');
 302        }
 303  
 304        console.log(`Profile published to ${successes.length}/${results.length} relays`);
 305  
 306        // Clear cached profile and refetch
 307        await this.#profileMetadata.clearCacheForPubkey(this.#pubkey);
 308        await this.#profileMetadata.fetchProfile(this.#pubkey);
 309  
 310        // Navigate back to identity page
 311        this.#router.navigateByUrl('/home/identity');
 312      } catch (error) {
 313        console.error('Failed to save profile:', error);
 314        this.alertMessage = error instanceof Error ? error.message : 'Failed to save profile';
 315        setTimeout(() => {
 316          this.alertMessage = undefined;
 317        }, 4500);
 318      } finally {
 319        this.saving = false;
 320      }
 321    }
 322  
 323    onClickCancel() {
 324      this.#router.navigateByUrl('/home/identity');
 325    }
 326  }
 327