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:
@@ -6,23 +6,16 @@
|
||||
<div class="sam-flex-row gap-h">
|
||||
<lib-relay-rw
|
||||
type="read"
|
||||
[(model)]="relay.read"
|
||||
(modelChange)="onRelayChanged(relay)"
|
||||
[model]="relay.read"
|
||||
[readonly]="true"
|
||||
></lib-relay-rw>
|
||||
<lib-relay-rw
|
||||
type="write"
|
||||
[(model)]="relay.write"
|
||||
(modelChange)="onRelayChanged(relay)"
|
||||
[model]="relay.write"
|
||||
[readonly]="true"
|
||||
></lib-relay-rw>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lib-icon-button
|
||||
icon="trash"
|
||||
title="Remove relay"
|
||||
(click)="onClickRemoveRelay(relay)"
|
||||
style="margin-top: 4px"
|
||||
></lib-icon-button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -31,48 +24,37 @@
|
||||
icon="chevron-left"
|
||||
(click)="navigateBack()"
|
||||
></lib-icon-button>
|
||||
<span>Relays</span>
|
||||
<span class="header-title">Relays</span>
|
||||
</div>
|
||||
|
||||
<div class="sam-mb-2 sam-flex-row gap">
|
||||
<div class="sam-flex-column sam-flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
(focus)="addRelayInputHasFocus = true"
|
||||
(blur)="addRelayInputHasFocus = false"
|
||||
[placeholder]="addRelayInputHasFocus ? 'server.com' : 'Add a relay'"
|
||||
class="form-control"
|
||||
[(ngModel)]="newRelay.url"
|
||||
(ngModelChange)="evaluateCanAdd()"
|
||||
/>
|
||||
<div class="sam-flex-row gap-h" style="margin-top: 4px">
|
||||
<lib-relay-rw
|
||||
class="sam-flex-grow"
|
||||
type="read"
|
||||
[(model)]="newRelay.read"
|
||||
(modelChange)="evaluateCanAdd()"
|
||||
></lib-relay-rw>
|
||||
<lib-relay-rw
|
||||
class="sam-flex-grow"
|
||||
type="write"
|
||||
[(model)]="newRelay.write"
|
||||
(modelChange)="evaluateCanAdd()"
|
||||
></lib-relay-rw>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
style="height: 100%"
|
||||
(click)="onClickAddRelay()"
|
||||
[disabled]="!canAdd"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<div class="info-banner">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
|
||||
</div>
|
||||
|
||||
@for(relay of relays; track relay) {
|
||||
@if(loading) {
|
||||
<div class="loading-state">
|
||||
<i class="bi bi-circle color-activity"></i>
|
||||
<span>Fetching relay list...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if(!loading && errorMessage) {
|
||||
<div class="error-state">
|
||||
<i class="bi bi-exclamation-triangle sam-color-danger"></i>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if(!loading && !errorMessage && relays.length === 0) {
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-broadcast"></i>
|
||||
<span>No relay list found</span>
|
||||
<span class="hint">Publish a NIP-65 relay list using a Nostr client to see your relays here.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for(relay of relays; track relay.url) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="relayTemplate; context: { relay: relay }"
|
||||
></ng-container>
|
||||
|
||||
@@ -17,14 +17,81 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: var(--size-h);
|
||||
padding: var(--size-h) var(--size);
|
||||
margin-bottom: var(--size);
|
||||
background: var(--background-light);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
i {
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--size-h);
|
||||
padding: var(--size-2);
|
||||
color: var(--muted-foreground);
|
||||
|
||||
i {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-activity {
|
||||
color: var(--muted-foreground);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.relay {
|
||||
margin-bottom: 4px;
|
||||
padding: 4px 8px 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--background-light);
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
NavComponent,
|
||||
Relay_DECRYPTED,
|
||||
Nip65Relay,
|
||||
NostrHelper,
|
||||
RelayListService,
|
||||
RelayRwComponent,
|
||||
StorageService,
|
||||
VisualRelayPipe,
|
||||
} from '@common';
|
||||
|
||||
interface NewRelay {
|
||||
url: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-relays',
|
||||
imports: [
|
||||
IconButtonComponent,
|
||||
FormsModule,
|
||||
RelayRwComponent,
|
||||
NgTemplateOutlet,
|
||||
VisualRelayPipe,
|
||||
@@ -32,100 +26,52 @@ interface NewRelay {
|
||||
})
|
||||
export class RelaysComponent extends NavComponent implements OnInit {
|
||||
identity?: Identity_DECRYPTED;
|
||||
relays: Relay_DECRYPTED[] = [];
|
||||
addRelayInputHasFocus = false;
|
||||
newRelay: NewRelay = {
|
||||
url: '',
|
||||
read: true,
|
||||
write: true,
|
||||
};
|
||||
canAdd = false;
|
||||
relays: Nip65Relay[] = [];
|
||||
loading = true;
|
||||
errorMessage = '';
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #relayListService = inject(RelayListService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const selectedIdentityId =
|
||||
this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!selectedIdentityId) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.#loadData(selectedIdentityId);
|
||||
}
|
||||
|
||||
evaluateCanAdd() {
|
||||
let canAdd = true;
|
||||
|
||||
if (!this.newRelay.url) {
|
||||
canAdd = false;
|
||||
} else if (!this.newRelay.read && !this.newRelay.write) {
|
||||
canAdd = false;
|
||||
}
|
||||
|
||||
this.canAdd = canAdd;
|
||||
}
|
||||
|
||||
async onClickRemoveRelay(relay: Relay_DECRYPTED) {
|
||||
if (!this.identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
async #loadData(identityId: string) {
|
||||
try {
|
||||
await this.#storage.deleteRelay(relay.id);
|
||||
this.#loadData(this.identity.id);
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
if (!this.identity) {
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Identity not found';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the pubkey for this identity
|
||||
const pubkey = NostrHelper.pubkeyFromPrivkey(this.identity.privkey);
|
||||
|
||||
// Fetch NIP-65 relay list
|
||||
const nip65Relays = await this.#relayListService.fetchRelayList(pubkey);
|
||||
this.relays = nip65Relays;
|
||||
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
console.error('Failed to load relay list:', error);
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Failed to fetch relay list';
|
||||
}
|
||||
}
|
||||
|
||||
async onClickAddRelay() {
|
||||
if (!this.identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#storage.addRelay({
|
||||
identityId: this.identity.id,
|
||||
url: 'wss://' + this.newRelay.url.toLowerCase(),
|
||||
read: this.newRelay.read,
|
||||
write: this.newRelay.write,
|
||||
});
|
||||
|
||||
this.newRelay = {
|
||||
url: '',
|
||||
read: true,
|
||||
write: true,
|
||||
};
|
||||
this.evaluateCanAdd();
|
||||
this.#loadData(this.identity.id);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
async onRelayChanged(relay: Relay_DECRYPTED) {
|
||||
try {
|
||||
await this.#storage.updateRelay(relay);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
#loadData(identityId: string) {
|
||||
this.identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
const relays: Relay_DECRYPTED[] = [];
|
||||
(this.#storage.getBrowserSessionHandler().browserSessionData?.relays ?? [])
|
||||
.filter((x) => x.identityId === identityId)
|
||||
.forEach((x) => {
|
||||
relays.push(JSON.parse(JSON.stringify(x)));
|
||||
});
|
||||
this.relays = relays;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> </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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
placeholder="vault password"
|
||||
[(ngModel)]="loginPassword"
|
||||
(keyup.enter)="loginPassword && loginVault()"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ConfirmComponent, StartupService, StorageService } from '@common';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
NostrHelper,
|
||||
ProfileMetadataService,
|
||||
StartupService,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-service-config';
|
||||
|
||||
@Component({
|
||||
@@ -17,6 +23,7 @@ export class VaultLoginComponent {
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #startup = inject(StartupService);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
|
||||
toggleType(element: HTMLInputElement) {
|
||||
if (element.type === 'password') {
|
||||
@@ -33,7 +40,11 @@ export class VaultLoginComponent {
|
||||
|
||||
try {
|
||||
await this.#storage.unlockVault(this.loginPassword);
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
|
||||
// Fetch profile metadata for all identities in the background
|
||||
this.#fetchAllProfiles();
|
||||
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
} catch (error) {
|
||||
this.showInvalidPasswordAlert = true;
|
||||
console.log(error);
|
||||
@@ -43,6 +54,30 @@ export class VaultLoginComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profile metadata for all identities (runs in background)
|
||||
*/
|
||||
async #fetchAllProfiles() {
|
||||
try {
|
||||
const identities =
|
||||
this.#storage.getBrowserSessionHandler().browserSessionData?.identities ?? [];
|
||||
|
||||
if (identities.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all pubkeys from identities
|
||||
const pubkeys = identities.map((identity) =>
|
||||
NostrHelper.pubkeyFromPrivkey(identity.privkey)
|
||||
);
|
||||
|
||||
// Fetch all profiles in parallel
|
||||
await this.#profileMetadata.fetchProfiles(pubkeys);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profiles:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onClickResetExtension() {
|
||||
try {
|
||||
await this.#storage.resetExtension();
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Plebeian Signer</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Prevent white flash on load - default to dark, light theme overrides */
|
||||
html, body { background-color: #0a0a0a; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
html, body { background-color: #ffffff; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -4,6 +4,20 @@ import { Nip07Method } from '@common';
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
// Fallback UUID generator for contexts where crypto.randomUUID is unavailable
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback using crypto.getRandomValues
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
|
||||
const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
class Messenger {
|
||||
#requests = new Map<
|
||||
string,
|
||||
@@ -18,7 +32,7 @@ class Messenger {
|
||||
}
|
||||
|
||||
async request(method: Nip07Method, params: any): Promise<any> {
|
||||
const id = crypto.randomUUID();
|
||||
const id = generateUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#requests.set(id, { resolve, reject });
|
||||
|
||||
@@ -25,3 +25,79 @@ button {
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
// Override Bootstrap primary button with orange
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary-border);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--primary-hover);
|
||||
border-color: var(--primary-border-hover);
|
||||
color: var(--primary-foreground-hover);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: var(--primary-hover);
|
||||
border-color: var(--primary-border-hover);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary-border);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 62, 181, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
// Style for outline variant
|
||||
.btn-outline-primary {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary-border);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary-border);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Form inputs styling
|
||||
.form-control {
|
||||
background-color: var(--input);
|
||||
border-color: var(--input-border);
|
||||
color: var(--foreground);
|
||||
|
||||
&:focus {
|
||||
background-color: var(--input);
|
||||
border-color: var(--primary);
|
||||
color: var(--foreground);
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 62, 181, 0.25);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure alerts work in both themes
|
||||
.alert-danger {
|
||||
background-color: var(--destructive);
|
||||
border-color: var(--destructive);
|
||||
color: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
// Cards and panels
|
||||
.sam-card {
|
||||
background: var(--background-light);
|
||||
border-color: var(--border);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user