- Upgrade vault encryption from PBKDF2 (1000 iterations) to Argon2id
(256MB memory, 8 iterations, 4 threads, ~3 second derivation)
- Add automatic migration from v1 to v2 vault format on unlock
- Add WebAssembly CSP support for hash-wasm Argon2id implementation
- Add NIP-42 relay authentication support for auth-required relays
- Add profile edit feature with pencil icon on identity page
- Add direct NIP-05 validation (removes NDK dependency for validation)
- Add deriving modal with progress timer during key derivation
- Add client tag "plebeian-signer" to profile events
- Fix modal colors (dark theme for visibility)
- Fix NIP-05 badge styling to include check/error indicator
- Add release zip packages for Chrome and Firefox
New files:
- projects/common/src/lib/helpers/argon2-crypto.ts
- projects/common/src/lib/helpers/websocket-auth.ts
- projects/common/src/lib/helpers/nip05-validator.ts
- projects/common/src/lib/components/deriving-modal/
- projects/{chrome,firefox}/src/app/components/profile-edit/
- releases/plebeian-signer-{chrome,firefox}-v1.0.0.zip
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import { Component, inject, OnInit } from '@angular/core';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Router } from '@angular/router';
|
|
import {
|
|
FALLBACK_PROFILE_RELAYS,
|
|
NavComponent,
|
|
NostrHelper,
|
|
ProfileMetadataService,
|
|
RelayListService,
|
|
StorageService,
|
|
ToastComponent,
|
|
publishToRelaysWithAuth,
|
|
} from '@common';
|
|
import { SimplePool } from 'nostr-tools/pool';
|
|
import { finalizeEvent } from 'nostr-tools';
|
|
import { hexToBytes } from '@noble/hashes/utils';
|
|
|
|
interface ProfileFormData {
|
|
name: string;
|
|
display_name: string;
|
|
picture: string;
|
|
banner: string;
|
|
website: string;
|
|
about: string;
|
|
nip05: string;
|
|
lud16: string;
|
|
lnurl: string;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-profile-edit',
|
|
templateUrl: './profile-edit.component.html',
|
|
styleUrl: './profile-edit.component.scss',
|
|
imports: [FormsModule, ToastComponent],
|
|
})
|
|
export class ProfileEditComponent extends NavComponent implements OnInit {
|
|
readonly #storage = inject(StorageService);
|
|
readonly #router = inject(Router);
|
|
readonly #profileMetadata = inject(ProfileMetadataService);
|
|
readonly #relayList = inject(RelayListService);
|
|
|
|
profile: ProfileFormData = {
|
|
name: '',
|
|
display_name: '',
|
|
picture: '',
|
|
banner: '',
|
|
website: '',
|
|
about: '',
|
|
nip05: '',
|
|
lud16: '',
|
|
lnurl: '',
|
|
};
|
|
|
|
// Store original event content to preserve extra fields
|
|
#originalContent: Record<string, unknown> = {};
|
|
#originalTags: string[][] = [];
|
|
|
|
loading = true;
|
|
saving = false;
|
|
alertMessage: string | undefined;
|
|
#privkey: string | undefined;
|
|
#pubkey: string | undefined;
|
|
|
|
async ngOnInit() {
|
|
await this.#loadProfile();
|
|
}
|
|
|
|
async #loadProfile() {
|
|
try {
|
|
const selectedIdentityId =
|
|
this.#storage.getBrowserSessionHandler().browserSessionData
|
|
?.selectedIdentityId ?? null;
|
|
|
|
const identity = this.#storage
|
|
.getBrowserSessionHandler()
|
|
.browserSessionData?.identities.find(
|
|
(x) => x.id === selectedIdentityId
|
|
);
|
|
|
|
if (!identity) {
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
|
|
this.#privkey = identity.privkey;
|
|
this.#pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
|
|
|
// Initialize services
|
|
await this.#profileMetadata.initialize();
|
|
|
|
// Try to get cached profile first
|
|
const cachedProfile = this.#profileMetadata.getCachedProfile(this.#pubkey);
|
|
if (cachedProfile) {
|
|
this.profile = {
|
|
name: cachedProfile.name || '',
|
|
display_name: cachedProfile.display_name || cachedProfile.displayName || '',
|
|
picture: cachedProfile.picture || '',
|
|
banner: cachedProfile.banner || '',
|
|
website: cachedProfile.website || '',
|
|
about: cachedProfile.about || '',
|
|
nip05: cachedProfile.nip05 || '',
|
|
lud16: cachedProfile.lud16 || '',
|
|
lnurl: cachedProfile.lud06 || '',
|
|
};
|
|
}
|
|
|
|
// Fetch the actual kind 0 event to get original content and tags
|
|
await this.#fetchOriginalEvent();
|
|
|
|
this.loading = false;
|
|
} catch (error) {
|
|
console.error('Failed to load profile:', error);
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
async #fetchOriginalEvent() {
|
|
if (!this.#pubkey) return;
|
|
|
|
const pool = new SimplePool();
|
|
try {
|
|
const events = await this.#queryWithTimeout(
|
|
pool,
|
|
FALLBACK_PROFILE_RELAYS,
|
|
[{ kinds: [0], authors: [this.#pubkey] }],
|
|
10000
|
|
);
|
|
|
|
if (events.length > 0) {
|
|
// Get the most recent event
|
|
const latestEvent = events.reduce((latest, event) =>
|
|
event.created_at > latest.created_at ? event : latest
|
|
);
|
|
|
|
// Store original tags (excluding the ones we'll update)
|
|
this.#originalTags = latestEvent.tags.filter(
|
|
(tag: string[]) =>
|
|
tag[0] !== 'name' &&
|
|
tag[0] !== 'display_name' &&
|
|
tag[0] !== 'picture' &&
|
|
tag[0] !== 'banner' &&
|
|
tag[0] !== 'website' &&
|
|
tag[0] !== 'about' &&
|
|
tag[0] !== 'nip05' &&
|
|
tag[0] !== 'lud16' &&
|
|
tag[0] !== 'client'
|
|
);
|
|
|
|
// Parse and store original content
|
|
try {
|
|
this.#originalContent = JSON.parse(latestEvent.content);
|
|
|
|
// Update form with values from event content
|
|
this.profile = {
|
|
name: (this.#originalContent['name'] as string) || '',
|
|
display_name:
|
|
(this.#originalContent['display_name'] as string) ||
|
|
(this.#originalContent['displayName'] as string) ||
|
|
'',
|
|
picture: (this.#originalContent['picture'] as string) || '',
|
|
banner: (this.#originalContent['banner'] as string) || '',
|
|
website: (this.#originalContent['website'] as string) || '',
|
|
about: (this.#originalContent['about'] as string) || '',
|
|
nip05: (this.#originalContent['nip05'] as string) || '',
|
|
lud16: (this.#originalContent['lud16'] as string) || '',
|
|
lnurl: (this.#originalContent['lnurl'] as string) || '',
|
|
};
|
|
} catch {
|
|
console.error('Failed to parse profile content');
|
|
}
|
|
}
|
|
} finally {
|
|
pool.close(FALLBACK_PROFILE_RELAYS);
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
|
|
return new Promise((resolve) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const events: any[] = [];
|
|
let settled = false;
|
|
|
|
const timeout = setTimeout(() => {
|
|
if (!settled) {
|
|
settled = true;
|
|
resolve(events);
|
|
}
|
|
}, timeoutMs);
|
|
|
|
const sub = pool.subscribeMany(relays, filters, {
|
|
onevent(event) {
|
|
events.push(event);
|
|
},
|
|
oneose() {
|
|
if (!settled) {
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
sub.close();
|
|
resolve(events);
|
|
}
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
async onClickSave() {
|
|
if (this.saving || !this.#privkey || !this.#pubkey) return;
|
|
|
|
this.saving = true;
|
|
this.alertMessage = undefined;
|
|
|
|
try {
|
|
// Build the content JSON, preserving extra fields
|
|
const content: Record<string, unknown> = { ...this.#originalContent };
|
|
|
|
// Update with form values
|
|
content['name'] = this.profile.name;
|
|
content['display_name'] = this.profile.display_name;
|
|
content['displayName'] = this.profile.display_name; // Some clients use this
|
|
content['picture'] = this.profile.picture;
|
|
content['banner'] = this.profile.banner;
|
|
content['website'] = this.profile.website;
|
|
content['about'] = this.profile.about;
|
|
content['nip05'] = this.profile.nip05;
|
|
content['lud16'] = this.profile.lud16;
|
|
if (this.profile.lnurl) {
|
|
content['lnurl'] = this.profile.lnurl;
|
|
}
|
|
content['pubkey'] = this.#pubkey;
|
|
|
|
// Build tags array, preserving extra tags
|
|
const tags: string[][] = [...this.#originalTags];
|
|
|
|
// Add standard tags
|
|
if (this.profile.name) tags.push(['name', this.profile.name]);
|
|
if (this.profile.display_name) tags.push(['display_name', this.profile.display_name]);
|
|
if (this.profile.picture) tags.push(['picture', this.profile.picture]);
|
|
if (this.profile.banner) tags.push(['banner', this.profile.banner]);
|
|
if (this.profile.website) tags.push(['website', this.profile.website]);
|
|
if (this.profile.about) tags.push(['about', this.profile.about]);
|
|
if (this.profile.nip05) tags.push(['nip05', this.profile.nip05]);
|
|
if (this.profile.lud16) tags.push(['lud16', this.profile.lud16]);
|
|
|
|
// Add alt tag if not present
|
|
if (!tags.some(t => t[0] === 'alt')) {
|
|
tags.push(['alt', `User profile for ${this.profile.name || this.profile.display_name || 'user'}`]);
|
|
}
|
|
|
|
// Always add client tag
|
|
tags.push(['client', 'plebeian-signer']);
|
|
|
|
// Create the unsigned event
|
|
const unsignedEvent = {
|
|
kind: 0,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags,
|
|
content: JSON.stringify(content),
|
|
};
|
|
|
|
// Sign the event
|
|
const privkeyBytes = hexToBytes(this.#privkey);
|
|
const signedEvent = finalizeEvent(unsignedEvent, privkeyBytes);
|
|
|
|
// Get write relays from NIP-65 or use fallback
|
|
await this.#relayList.initialize();
|
|
const writeRelays = await this.#relayList.fetchRelayList(this.#pubkey);
|
|
let relayUrls: string[];
|
|
|
|
if (writeRelays.length > 0) {
|
|
// Filter to write relays only
|
|
relayUrls = writeRelays
|
|
.filter(r => r.write)
|
|
.map(r => r.url);
|
|
|
|
// If no write relays found, use all relays
|
|
if (relayUrls.length === 0) {
|
|
relayUrls = writeRelays.map(r => r.url);
|
|
}
|
|
} else {
|
|
// Use fallback relays
|
|
relayUrls = FALLBACK_PROFILE_RELAYS;
|
|
}
|
|
|
|
// Publish to relays with NIP-42 authentication support
|
|
const results = await publishToRelaysWithAuth(
|
|
relayUrls,
|
|
signedEvent,
|
|
this.#privkey
|
|
);
|
|
|
|
// Count successes
|
|
const successes = results.filter(r => r.success);
|
|
const failures = results.filter(r => !r.success);
|
|
|
|
if (failures.length > 0) {
|
|
console.log('Some relays failed:', failures.map(f => `${f.relay}: ${f.message}`));
|
|
}
|
|
|
|
if (successes.length === 0) {
|
|
throw new Error('Failed to publish to any relay');
|
|
}
|
|
|
|
console.log(`Profile published to ${successes.length}/${results.length} relays`);
|
|
|
|
// Clear cached profile and refetch
|
|
await this.#profileMetadata.clearCacheForPubkey(this.#pubkey);
|
|
await this.#profileMetadata.fetchProfile(this.#pubkey);
|
|
|
|
// Navigate back to identity page
|
|
this.#router.navigateByUrl('/home/identity');
|
|
} catch (error) {
|
|
console.error('Failed to save profile:', error);
|
|
this.alertMessage = error instanceof Error ? error.message : 'Failed to save profile';
|
|
setTimeout(() => {
|
|
this.alertMessage = undefined;
|
|
}, 4500);
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
}
|
|
|
|
onClickCancel() {
|
|
this.#router.navigateByUrl('/home/identity');
|
|
}
|
|
}
|