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