Add NIP-65 relay list display and improve identity UI

- Add NIP-65 relay list service to fetch kind 10002 events from relays
- Replace configurable relay page with read-only NIP-65 relay display
- Update identity page to show display name and username in same badge
- Use reglisse heading font for titles throughout the UI
- Navigate to You page after vault unlock instead of identities list
- Add autofocus to vault password input field
- Add profile metadata service for fetching kind 0 events
- Add readonly mode to relay-rw component

Files modified:
- package.json (version bump to 0.0.6)
- projects/common/src/lib/services/relay-list/relay-list.service.ts (new)
- projects/common/src/lib/services/profile-metadata/profile-metadata.service.ts (new)
- projects/common/src/lib/constants/fallback-relays.ts (new)
- projects/*/src/app/components/home/identity/* (UI improvements)
- projects/*/src/app/components/edit-identity/relays/* (NIP-65 display)
- projects/*/src/app/components/vault-login/* (autofocus, navigation)
- projects/common/src/lib/styles/* (heading fonts)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-17 15:21:57 +01:00
parent fe886d2101
commit 578f3e08ff
39 changed files with 1900 additions and 536 deletions

View File

@@ -3,52 +3,66 @@
<span>You</span>
</div>
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap center">
<div class="picture-frame" [class.padding]="!loadedData.profile?.image">
<div class="identity-container">
<!-- Banner background -->
<div
class="banner-background"
[style.background-image]="bannerUrl ? 'url(' + bannerUrl + ')' : 'none'"
>
<div class="banner-overlay"></div>
<div class="profile-content">
<!-- Avatar -->
<div class="avatar-frame" [class.has-image]="avatarUrl">
<img
[src]="
!loadedData.profile?.image
? 'person-fill.svg'
: loadedData.profile?.image
"
[src]="avatarUrl || 'person-fill.svg'"
alt=""
class="avatar-image"
/>
</div>
<!-- Display name (primary, large) -->
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
<span class="name" (click)="onClickShowDetails()">
{{ selectedIdentity?.nick }}
</span>
<div class="name-badge-container" (click)="onClickShowDetails()">
<span class="display-name">
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
</span>
@if(username) {
<span class="username">
{{ username }}
</span>
}
</div>
@if(loadedData.profile) {
<div class="sam-flex-row gap-h">
@if(loadedData.validating) {
<!-- NIP-05 verification -->
@if(profile?.nip05) {
<div class="nip05-row">
@if(validating) {
<i class="bi bi-circle color-activity"></i>
} @else { @if(loadedData.nip05isValidated) {
} @else { @if(nip05isValidated) {
<i class="bi bi-patch-check sam-color-primary"></i>
} @else {
<i class="bi bi-exclamation-octagon-fill sam-color-danger"></i>
} }
<span class="sam-color-primary">{{
loadedData.profile.nip05 | visualNip05
<span class="nip05-badge">{{
profile?.nip05 | visualNip05
}}</span>
</div>
} @else {
<span>&nbsp;</span>
}
<lib-pubkey
[value]="selectedIdentityNpub ?? 'na'"
[first]="14"
[last]="8"
(click)="
copyToClipboard(selectedIdentityNpub);
toast.show('Copied to clipboard')
"
></lib-pubkey>
<!-- npub display -->
<div class="npub-wrapper">
<lib-pubkey
[value]="selectedIdentityNpub ?? 'na'"
[first]="14"
[last]="8"
(click)="
copyToClipboard(selectedIdentityNpub);
toast.show('Copied to clipboard')
"
></lib-pubkey>
</div>
</div>
</div>
</div>

View File

@@ -3,39 +3,162 @@
display: flex;
flex-direction: column;
.vertically-centered {
height: 100%;
.identity-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.banner-background {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
background-size: cover;
background-position: center;
background-color: var(--background-light);
// Create square aspect ratio centered on vertical
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: inherit;
background-size: cover;
background-position: center center;
}
}
.name {
font-size: 20px;
font-weight: 500;
cursor: pointer;
max-width: 343px;
overflow-x: hidden;
text-overflow: ellipsis;
.banner-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.5) 50%,
rgba(0, 0, 0, 0.3) 100%
);
z-index: 1;
}
.picture-frame {
.profile-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
}
.avatar-frame {
height: 120px;
width: 120px;
border: 2px solid white;
border: 3px solid rgba(255, 255, 255, 0.9);
border-radius: 100%;
&.padding {
padding: 12px;
background: var(--background);
padding: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
&.has-image {
padding: 0;
}
img {
.avatar-image {
border-radius: 100%;
width: 100%;
height: 100%;
object-fit: cover;
}
}
// Common badge styling - rounded corners, black background
%text-badge {
background-color: rgba(0, 0, 0, 0.75);
padding: 6px 14px;
border-radius: 6px;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
backdrop-filter: blur(4px);
}
.name-badge-container {
@extend %text-badge;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
white-space: normal;
text-align: center;
&:hover {
background-color: rgba(0, 0, 0, 0.85);
}
.display-name {
font-family: var(--font-heading);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.05rem;
color: #ffffff;
}
.username {
font-size: 13px;
font-weight: 400;
color: rgba(255, 255, 255, 0.85);
}
}
.nip05-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
i {
font-size: 16px;
}
}
.nip05-badge {
@extend %text-badge;
font-size: 13px;
color: var(--primary);
}
.npub-wrapper {
@extend %text-badge;
padding: 8px 14px;
lib-pubkey {
cursor: pointer;
}
}
.color-activity {
color: var(--bs-border-color);
color: var(--muted-foreground);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
}

View File

@@ -3,19 +3,14 @@ import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
} from '@common';
import NDK, { NDKUserProfile } from '@nostr-dev-kit/ndk';
interface LoadedData {
profile: NDKUserProfile | undefined;
nip05: string | undefined;
nip05isValidated: boolean | undefined;
validating: boolean;
}
import NDK from '@nostr-dev-kit/ndk';
@Component({
selector: 'app-identity',
@@ -26,20 +21,35 @@ interface LoadedData {
export class IdentityComponent implements OnInit {
selectedIdentity: Identity_DECRYPTED | undefined;
selectedIdentityNpub: string | undefined;
loadedData: LoadedData = {
profile: undefined,
nip05: undefined,
nip05isValidated: undefined,
validating: false,
};
profile: ProfileMetadata | null = null;
nip05isValidated: boolean | undefined;
validating = false;
loading = true;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
ngOnInit(): void {
this.#loadData();
}
get displayName(): string | undefined {
return this.#profileMetadata.getDisplayName(this.profile);
}
get username(): string | undefined {
return this.#profileMetadata.getUsername(this.profile);
}
get avatarUrl(): string | undefined {
return this.profile?.picture;
}
get bannerUrl(): string | undefined {
return this.profile?.banner;
}
copyToClipboard(pubkey: string | undefined) {
if (!pubkey) {
return;
@@ -70,6 +80,7 @@ export class IdentityComponent implements OnInit {
);
if (!identity) {
this.loading = false;
return;
}
@@ -77,41 +88,66 @@ export class IdentityComponent implements OnInit {
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
this.selectedIdentityNpub = NostrHelper.pubkey2npub(pubkey);
// Determine the user's relays to check for his profile.
// Initialize the profile metadata service (loads cache from storage)
await this.#profileMetadata.initialize();
// Check if we have cached profile data
const cachedProfile = this.#profileMetadata.getCachedProfile(pubkey);
if (cachedProfile) {
this.profile = cachedProfile;
this.loading = false;
// Validate NIP-05 if present (in background)
if (cachedProfile.nip05) {
this.#validateNip05(pubkey, cachedProfile.nip05);
}
return; // Use cached data, don't fetch again
}
// No cached data, fetch from relays
this.loading = true;
const fetchedProfile = await this.#profileMetadata.fetchProfile(pubkey);
if (fetchedProfile) {
this.profile = fetchedProfile;
// Validate NIP-05 if present
if (fetchedProfile.nip05) {
this.#validateNip05(pubkey, fetchedProfile.nip05);
}
}
this.loading = false;
} catch (error) {
console.error(error);
this.loading = false;
}
}
async #validateNip05(pubkey: string, nip05: string) {
try {
this.validating = true;
// Get relays for validation
const relays =
this.#storage
.getBrowserSessionHandler()
.browserSessionData?.relays.filter(
(x) => x.identityId === identity.id
(x) => x.identityId === this.selectedIdentity?.id
) ?? [];
if (relays.length === 0) {
return;
}
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
// Fetch the user's profile.
const ndk = new NDK({
explicitRelayUrls: relevantRelays,
});
await ndk.connect();
const user = ndk.getUser({
pubkey: NostrHelper.pubkeyFromPrivkey(identity.privkey),
//relayUrls: relevantRelays,
});
this.loadedData.profile = (await user.fetchProfile()) ?? undefined;
if (this.loadedData.profile?.nip05) {
this.loadedData.validating = true;
this.loadedData.nip05isValidated =
(await user.validateNip05(this.loadedData.profile.nip05)) ??
undefined;
this.loadedData.validating = false;
if (relevantRelays.length > 0) {
const ndk = new NDK({
explicitRelayUrls: relevantRelays,
});
await ndk.connect();
const user = ndk.getUser({ pubkey });
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
}
this.validating = false;
} catch (error) {
console.error(error);
// TODO
console.error('NIP-05 validation failed:', error);
this.validating = false;
}
}
}