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:
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "plebeian-signer",
|
"name": "plebeian-signer",
|
||||||
"version": "0.0.5",
|
"version": "0.0.6",
|
||||||
"custom": {
|
"custom": {
|
||||||
"chrome": {
|
"chrome": {
|
||||||
"version": "0.0.5"
|
"version": "0.0.6"
|
||||||
},
|
},
|
||||||
"firefox": {
|
"firefox": {
|
||||||
"version": "0.0.5"
|
"version": "0.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||||
"version": "0.0.5",
|
"version": "0.0.6",
|
||||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -42,10 +42,7 @@
|
|||||||
],
|
],
|
||||||
"matches": [
|
"matches": [
|
||||||
"https://*/*",
|
"https://*/*",
|
||||||
"http://localhost:*/*",
|
"http://*/*"
|
||||||
"http://0.0.0.0:*/*",
|
|
||||||
"http://127.0.0.1:*/*",
|
|
||||||
"http://*.localhost/*"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
||||||
<html data-bs-theme="dark">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Plebeian Signer - Options</title>
|
<title>Plebeian Signer - Options</title>
|
||||||
<link rel="stylesheet" type="text/css" href="styles.css" />
|
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||||
<script src="scripts.js"></script>
|
<script src="scripts.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
/* Prevent white flash on load */
|
||||||
|
html { background-color: #0a0a0a; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html { background-color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
color: #ffffff;
|
color: var(--foreground);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
||||||
<html data-bs-theme="dark">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Plebeian Signer</title>
|
<title>Plebeian Signer</title>
|
||||||
<link rel="stylesheet" type="text/css" href="styles.css" />
|
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||||
<script src="scripts.js"></script>
|
<script src="scripts.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
/* Prevent white flash on load */
|
||||||
|
html { background-color: #0a0a0a; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html { background-color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
color: #ffffff;
|
color: var(--foreground);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +36,7 @@
|
|||||||
padding: var(--size);
|
padding: var(--size);
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #ffffff;
|
color: var(--foreground);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,16 @@
|
|||||||
<div class="sam-flex-row gap-h">
|
<div class="sam-flex-row gap-h">
|
||||||
<lib-relay-rw
|
<lib-relay-rw
|
||||||
type="read"
|
type="read"
|
||||||
[(model)]="relay.read"
|
[model]="relay.read"
|
||||||
(modelChange)="onRelayChanged(relay)"
|
[readonly]="true"
|
||||||
></lib-relay-rw>
|
></lib-relay-rw>
|
||||||
<lib-relay-rw
|
<lib-relay-rw
|
||||||
type="write"
|
type="write"
|
||||||
[(model)]="relay.write"
|
[model]="relay.write"
|
||||||
(modelChange)="onRelayChanged(relay)"
|
[readonly]="true"
|
||||||
></lib-relay-rw>
|
></lib-relay-rw>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<lib-icon-button
|
|
||||||
icon="trash"
|
|
||||||
title="Remove relay"
|
|
||||||
(click)="onClickRemoveRelay(relay)"
|
|
||||||
style="margin-top: 4px"
|
|
||||||
></lib-icon-button>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
@@ -31,48 +24,37 @@
|
|||||||
icon="chevron-left"
|
icon="chevron-left"
|
||||||
(click)="navigateBack()"
|
(click)="navigateBack()"
|
||||||
></lib-icon-button>
|
></lib-icon-button>
|
||||||
<span>Relays</span>
|
<span class="header-title">Relays</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sam-mb-2 sam-flex-row gap">
|
<div class="info-banner">
|
||||||
<div class="sam-flex-column sam-flex-grow">
|
<i class="bi bi-info-circle"></i>
|
||||||
<input
|
<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>
|
||||||
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>
|
</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
|
<ng-container
|
||||||
*ngTemplateOutlet="relayTemplate; context: { relay: relay }"
|
*ngTemplateOutlet="relayTemplate; context: { relay: relay }"
|
||||||
></ng-container>
|
></ng-container>
|
||||||
|
|||||||
@@ -17,14 +17,81 @@
|
|||||||
top: 0;
|
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 {
|
.relay {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
padding: 4px 8px 6px 8px;
|
padding: 4px 8px 6px 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--background-light-hover);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
import { NgTemplateOutlet } from '@angular/common';
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { Component, inject, OnInit } from '@angular/core';
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
IconButtonComponent,
|
IconButtonComponent,
|
||||||
Identity_DECRYPTED,
|
Identity_DECRYPTED,
|
||||||
NavComponent,
|
NavComponent,
|
||||||
Relay_DECRYPTED,
|
Nip65Relay,
|
||||||
|
NostrHelper,
|
||||||
|
RelayListService,
|
||||||
RelayRwComponent,
|
RelayRwComponent,
|
||||||
StorageService,
|
StorageService,
|
||||||
VisualRelayPipe,
|
VisualRelayPipe,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
|
|
||||||
interface NewRelay {
|
|
||||||
url: string;
|
|
||||||
read: boolean;
|
|
||||||
write: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-relays',
|
selector: 'app-relays',
|
||||||
imports: [
|
imports: [
|
||||||
IconButtonComponent,
|
IconButtonComponent,
|
||||||
FormsModule,
|
|
||||||
RelayRwComponent,
|
RelayRwComponent,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
VisualRelayPipe,
|
VisualRelayPipe,
|
||||||
@@ -32,100 +26,52 @@ interface NewRelay {
|
|||||||
})
|
})
|
||||||
export class RelaysComponent extends NavComponent implements OnInit {
|
export class RelaysComponent extends NavComponent implements OnInit {
|
||||||
identity?: Identity_DECRYPTED;
|
identity?: Identity_DECRYPTED;
|
||||||
relays: Relay_DECRYPTED[] = [];
|
relays: Nip65Relay[] = [];
|
||||||
addRelayInputHasFocus = false;
|
loading = true;
|
||||||
newRelay: NewRelay = {
|
errorMessage = '';
|
||||||
url: '',
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
};
|
|
||||||
canAdd = false;
|
|
||||||
|
|
||||||
readonly #activatedRoute = inject(ActivatedRoute);
|
readonly #activatedRoute = inject(ActivatedRoute);
|
||||||
readonly #storage = inject(StorageService);
|
readonly #storage = inject(StorageService);
|
||||||
|
readonly #relayListService = inject(RelayListService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const selectedIdentityId =
|
const selectedIdentityId =
|
||||||
this.#activatedRoute.parent?.snapshot.params['id'];
|
this.#activatedRoute.parent?.snapshot.params['id'];
|
||||||
if (!selectedIdentityId) {
|
if (!selectedIdentityId) {
|
||||||
|
this.loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#loadData(selectedIdentityId);
|
this.#loadData(selectedIdentityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluateCanAdd() {
|
async #loadData(identityId: string) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.#storage.deleteRelay(relay.id);
|
this.loading = true;
|
||||||
this.#loadData(this.identity.id);
|
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) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.error('Failed to load relay list:', error);
|
||||||
// TODO
|
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>
|
<span>You</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vertically-centered">
|
<div class="identity-container">
|
||||||
<div class="sam-flex-column center">
|
<!-- Banner background -->
|
||||||
<div class="sam-flex-column gap center">
|
<div
|
||||||
<div class="picture-frame" [class.padding]="!loadedData.profile?.image">
|
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
|
<img
|
||||||
[src]="
|
[src]="avatarUrl || 'person-fill.svg'"
|
||||||
!loadedData.profile?.image
|
|
||||||
? 'person-fill.svg'
|
|
||||||
: loadedData.profile?.image
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
|
class="avatar-image"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Display name (primary, large) -->
|
||||||
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
||||||
<span class="name" (click)="onClickShowDetails()">
|
<div class="name-badge-container" (click)="onClickShowDetails()">
|
||||||
{{ selectedIdentity?.nick }}
|
<span class="display-name">
|
||||||
</span>
|
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
||||||
|
</span>
|
||||||
|
@if(username) {
|
||||||
|
<span class="username">
|
||||||
|
{{ username }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@if(loadedData.profile) {
|
<!-- NIP-05 verification -->
|
||||||
<div class="sam-flex-row gap-h">
|
@if(profile?.nip05) {
|
||||||
@if(loadedData.validating) {
|
<div class="nip05-row">
|
||||||
|
@if(validating) {
|
||||||
<i class="bi bi-circle color-activity"></i>
|
<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>
|
<i class="bi bi-patch-check sam-color-primary"></i>
|
||||||
} @else {
|
} @else {
|
||||||
<i class="bi bi-exclamation-octagon-fill sam-color-danger"></i>
|
<i class="bi bi-exclamation-octagon-fill sam-color-danger"></i>
|
||||||
} }
|
} }
|
||||||
|
|
||||||
<span class="sam-color-primary">{{
|
<span class="nip05-badge">{{
|
||||||
loadedData.profile.nip05 | visualNip05
|
profile?.nip05 | visualNip05
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
|
||||||
<span> </span>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<lib-pubkey
|
<!-- npub display -->
|
||||||
[value]="selectedIdentityNpub ?? 'na'"
|
<div class="npub-wrapper">
|
||||||
[first]="14"
|
<lib-pubkey
|
||||||
[last]="8"
|
[value]="selectedIdentityNpub ?? 'na'"
|
||||||
(click)="
|
[first]="14"
|
||||||
copyToClipboard(selectedIdentityNpub);
|
[last]="8"
|
||||||
toast.show('Copied to clipboard')
|
(click)="
|
||||||
"
|
copyToClipboard(selectedIdentityNpub);
|
||||||
></lib-pubkey>
|
toast.show('Copied to clipboard')
|
||||||
|
"
|
||||||
|
></lib-pubkey>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,39 +3,162 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.vertically-centered {
|
.identity-container {
|
||||||
height: 100%;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-background {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: 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 {
|
.banner-overlay {
|
||||||
font-size: 20px;
|
position: absolute;
|
||||||
font-weight: 500;
|
top: 0;
|
||||||
cursor: pointer;
|
left: 0;
|
||||||
max-width: 343px;
|
right: 0;
|
||||||
overflow-x: hidden;
|
bottom: 0;
|
||||||
text-overflow: ellipsis;
|
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;
|
height: 120px;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
border: 2px solid white;
|
border: 3px solid rgba(255, 255, 255, 0.9);
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
&.padding {
|
background: var(--background);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
&.has-image {
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
.avatar-image {
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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-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 {
|
import {
|
||||||
Identity_DECRYPTED,
|
Identity_DECRYPTED,
|
||||||
NostrHelper,
|
NostrHelper,
|
||||||
|
ProfileMetadata,
|
||||||
|
ProfileMetadataService,
|
||||||
PubkeyComponent,
|
PubkeyComponent,
|
||||||
StorageService,
|
StorageService,
|
||||||
ToastComponent,
|
ToastComponent,
|
||||||
VisualNip05Pipe,
|
VisualNip05Pipe,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import NDK, { NDKUserProfile } from '@nostr-dev-kit/ndk';
|
import NDK from '@nostr-dev-kit/ndk';
|
||||||
|
|
||||||
interface LoadedData {
|
|
||||||
profile: NDKUserProfile | undefined;
|
|
||||||
nip05: string | undefined;
|
|
||||||
nip05isValidated: boolean | undefined;
|
|
||||||
validating: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-identity',
|
selector: 'app-identity',
|
||||||
@@ -26,20 +21,35 @@ interface LoadedData {
|
|||||||
export class IdentityComponent implements OnInit {
|
export class IdentityComponent implements OnInit {
|
||||||
selectedIdentity: Identity_DECRYPTED | undefined;
|
selectedIdentity: Identity_DECRYPTED | undefined;
|
||||||
selectedIdentityNpub: string | undefined;
|
selectedIdentityNpub: string | undefined;
|
||||||
loadedData: LoadedData = {
|
profile: ProfileMetadata | null = null;
|
||||||
profile: undefined,
|
nip05isValidated: boolean | undefined;
|
||||||
nip05: undefined,
|
validating = false;
|
||||||
nip05isValidated: undefined,
|
loading = true;
|
||||||
validating: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
readonly #storage = inject(StorageService);
|
readonly #storage = inject(StorageService);
|
||||||
readonly #router = inject(Router);
|
readonly #router = inject(Router);
|
||||||
|
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.#loadData();
|
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) {
|
copyToClipboard(pubkey: string | undefined) {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
return;
|
return;
|
||||||
@@ -70,6 +80,7 @@ export class IdentityComponent implements OnInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
|
this.loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,41 +88,66 @@ export class IdentityComponent implements OnInit {
|
|||||||
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
||||||
this.selectedIdentityNpub = NostrHelper.pubkey2npub(pubkey);
|
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 =
|
const relays =
|
||||||
this.#storage
|
this.#storage
|
||||||
.getBrowserSessionHandler()
|
.getBrowserSessionHandler()
|
||||||
.browserSessionData?.relays.filter(
|
.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);
|
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
|
||||||
|
|
||||||
// Fetch the user's profile.
|
if (relevantRelays.length > 0) {
|
||||||
const ndk = new NDK({
|
const ndk = new NDK({
|
||||||
explicitRelayUrls: relevantRelays,
|
explicitRelayUrls: relevantRelays,
|
||||||
});
|
});
|
||||||
|
await ndk.connect();
|
||||||
await ndk.connect();
|
const user = ndk.getUser({ pubkey });
|
||||||
|
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.validating = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error('NIP-05 validation failed:', error);
|
||||||
// TODO
|
this.validating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
placeholder="vault password"
|
placeholder="vault password"
|
||||||
[(ngModel)]="loginPassword"
|
[(ngModel)]="loginPassword"
|
||||||
(keyup.enter)="loginPassword && loginVault()"
|
(keyup.enter)="loginPassword && loginVault()"
|
||||||
|
autofocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
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';
|
import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-service-config';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -17,6 +23,7 @@ export class VaultLoginComponent {
|
|||||||
readonly #storage = inject(StorageService);
|
readonly #storage = inject(StorageService);
|
||||||
readonly #router = inject(Router);
|
readonly #router = inject(Router);
|
||||||
readonly #startup = inject(StartupService);
|
readonly #startup = inject(StartupService);
|
||||||
|
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||||
|
|
||||||
toggleType(element: HTMLInputElement) {
|
toggleType(element: HTMLInputElement) {
|
||||||
if (element.type === 'password') {
|
if (element.type === 'password') {
|
||||||
@@ -33,7 +40,11 @@ export class VaultLoginComponent {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.#storage.unlockVault(this.loginPassword);
|
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) {
|
} catch (error) {
|
||||||
this.showInvalidPasswordAlert = true;
|
this.showInvalidPasswordAlert = true;
|
||||||
console.log(error);
|
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() {
|
async onClickResetExtension() {
|
||||||
try {
|
try {
|
||||||
await this.#storage.resetExtension();
|
await this.#storage.resetExtension();
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-bs-theme="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Plebeian Signer</title>
|
<title>Plebeian Signer</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<!-- <link rel="icon" type="image/x-icon" href="favicon.ico"> -->
|
<!-- <link rel="icon" type="image/x-icon" href="favicon.ico"> -->
|
||||||
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ import { Nip07Method } from '@common';
|
|||||||
|
|
||||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
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 {
|
class Messenger {
|
||||||
#requests = new Map<
|
#requests = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -18,7 +32,7 @@ class Messenger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async request(method: Nip07Method, params: any): Promise<any> {
|
async request(method: Nip07Method, params: any): Promise<any> {
|
||||||
const id = crypto.randomUUID();
|
const id = generateUUID();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.#requests.set(id, { resolve, reject });
|
this.#requests.set(id, { resolve, reject });
|
||||||
|
|||||||
@@ -25,3 +25,79 @@ button {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-family: var(--font-sans);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.is-readonly {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
&.read {
|
&.read {
|
||||||
&:not(.is-selected) {
|
&:not(.is-selected) {
|
||||||
border: 1px solid var(--bs-green);
|
border: 1px solid var(--bs-green);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
export class RelayRwComponent {
|
export class RelayRwComponent {
|
||||||
@Input({ required: true }) type!: 'read' | 'write';
|
@Input({ required: true }) type!: 'read' | 'write';
|
||||||
@Input({ required: true }) model!: boolean;
|
@Input({ required: true }) model!: boolean;
|
||||||
|
@Input() readonly = false;
|
||||||
@Output() modelChange = new EventEmitter<boolean>();
|
@Output() modelChange = new EventEmitter<boolean>();
|
||||||
|
|
||||||
@HostBinding('class.read') get isRead() {
|
@HostBinding('class.read') get isRead() {
|
||||||
@@ -27,7 +28,14 @@ export class RelayRwComponent {
|
|||||||
return this.model;
|
return this.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostBinding('class.is-readonly') get isReadonly() {
|
||||||
|
return this.readonly;
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('click') onClick() {
|
@HostListener('click') onClick() {
|
||||||
|
if (this.readonly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.model = !this.model;
|
this.model = !this.model;
|
||||||
this.modelChange.emit(this.model);
|
this.modelChange.emit(this.model);
|
||||||
}
|
}
|
||||||
|
|||||||
11
projects/common/src/lib/constants/fallback-relays.ts
Normal file
11
projects/common/src/lib/constants/fallback-relays.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Fallback relays used for fetching profile metadata (kind 0 events).
|
||||||
|
* These are well-known relays that aggregate profile data.
|
||||||
|
*/
|
||||||
|
export const FALLBACK_PROFILE_RELAYS = [
|
||||||
|
'wss://relay.nostr.band/',
|
||||||
|
'wss://nostr.wine/',
|
||||||
|
'wss://nos.lol/',
|
||||||
|
'wss://relay.primal.net/',
|
||||||
|
'wss://purplepag.es/',
|
||||||
|
];
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { SimplePool } from 'nostr-tools/pool';
|
||||||
|
import { FALLBACK_PROFILE_RELAYS } from '../../constants/fallback-relays';
|
||||||
|
import { ProfileMetadata, ProfileMetadataCache } from '../storage/types';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
declare const chrome: any;
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
const FETCH_TIMEOUT_MS = 10000; // 10 seconds
|
||||||
|
const STORAGE_KEY = 'profileMetadataCache';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ProfileMetadataService {
|
||||||
|
#cache: ProfileMetadataCache = {};
|
||||||
|
#pool: SimplePool | null = null;
|
||||||
|
#fetchPromises = new Map<string, Promise<ProfileMetadata | null>>();
|
||||||
|
#initialized = false;
|
||||||
|
#initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the service by loading cache from session storage
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.#initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#initPromise) {
|
||||||
|
return this.#initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#initPromise = this.#loadCacheFromStorage();
|
||||||
|
await this.#initPromise;
|
||||||
|
this.#initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cache from browser session storage
|
||||||
|
*/
|
||||||
|
async #loadCacheFromStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Use chrome API (works in both Chrome and Firefox with polyfill)
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
|
||||||
|
const result = await chrome.storage.session.get(STORAGE_KEY);
|
||||||
|
if (result[STORAGE_KEY]) {
|
||||||
|
this.#cache = result[STORAGE_KEY];
|
||||||
|
// Clean up stale entries
|
||||||
|
this.#pruneStaleCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load profile cache from storage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save cache to browser session storage
|
||||||
|
*/
|
||||||
|
async #saveCacheToStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
|
||||||
|
await chrome.storage.session.set({ [STORAGE_KEY]: this.#cache });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save profile cache to storage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove stale entries from cache
|
||||||
|
*/
|
||||||
|
#pruneStaleCache(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const pubkey of Object.keys(this.#cache)) {
|
||||||
|
if (now - this.#cache[pubkey].fetchedAt > CACHE_TTL_MS) {
|
||||||
|
delete this.#cache[pubkey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the SimplePool instance, creating it if necessary
|
||||||
|
*/
|
||||||
|
#getPool(): SimplePool {
|
||||||
|
if (!this.#pool) {
|
||||||
|
this.#pool = new SimplePool();
|
||||||
|
}
|
||||||
|
return this.#pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached profile metadata for a pubkey
|
||||||
|
*/
|
||||||
|
getCachedProfile(pubkey: string): ProfileMetadata | null {
|
||||||
|
const cached = this.#cache[pubkey];
|
||||||
|
if (!cached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache is still valid
|
||||||
|
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) {
|
||||||
|
delete this.#cache[pubkey];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch profile metadata for a single pubkey
|
||||||
|
*/
|
||||||
|
async fetchProfile(pubkey: string): Promise<ProfileMetadata | null> {
|
||||||
|
// Ensure initialized
|
||||||
|
await this.initialize();
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.getCachedProfile(pubkey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already fetching
|
||||||
|
const existingPromise = this.#fetchPromises.get(pubkey);
|
||||||
|
if (existingPromise) {
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new fetch
|
||||||
|
const fetchPromise = this.#doFetchProfile(pubkey);
|
||||||
|
this.#fetchPromises.set(pubkey, fetchPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchPromise;
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this.#fetchPromises.delete(pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch profiles for multiple pubkeys in parallel
|
||||||
|
*/
|
||||||
|
async fetchProfiles(pubkeys: string[]): Promise<Map<string, ProfileMetadata | null>> {
|
||||||
|
// Ensure initialized
|
||||||
|
await this.initialize();
|
||||||
|
|
||||||
|
const results = new Map<string, ProfileMetadata | null>();
|
||||||
|
|
||||||
|
// Filter out pubkeys we already have cached
|
||||||
|
const uncachedPubkeys: string[] = [];
|
||||||
|
for (const pubkey of pubkeys) {
|
||||||
|
const cached = this.getCachedProfile(pubkey);
|
||||||
|
if (cached) {
|
||||||
|
results.set(pubkey, cached);
|
||||||
|
} else {
|
||||||
|
uncachedPubkeys.push(pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uncachedPubkeys.length === 0) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all uncached profiles
|
||||||
|
const pool = this.#getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await this.#queryWithTimeout(
|
||||||
|
pool,
|
||||||
|
FALLBACK_PROFILE_RELAYS,
|
||||||
|
[{ kinds: [0], authors: uncachedPubkeys }],
|
||||||
|
FETCH_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process events - keep only the most recent event per pubkey
|
||||||
|
const latestEvents = new Map<string, { created_at: number; content: string }>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const existing = latestEvents.get(event.pubkey);
|
||||||
|
if (!existing || event.created_at > existing.created_at) {
|
||||||
|
latestEvents.set(event.pubkey, {
|
||||||
|
created_at: event.created_at,
|
||||||
|
content: event.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and cache the profiles
|
||||||
|
for (const [pubkey, eventData] of latestEvents) {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(eventData.content);
|
||||||
|
const profile: ProfileMetadata = {
|
||||||
|
pubkey,
|
||||||
|
name: content.name,
|
||||||
|
display_name: content.display_name,
|
||||||
|
displayName: content.displayName,
|
||||||
|
picture: content.picture,
|
||||||
|
banner: content.banner,
|
||||||
|
about: content.about,
|
||||||
|
website: content.website,
|
||||||
|
nip05: content.nip05,
|
||||||
|
lud06: content.lud06,
|
||||||
|
lud16: content.lud16,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
};
|
||||||
|
this.#cache[pubkey] = profile;
|
||||||
|
results.set(pubkey, profile);
|
||||||
|
} catch {
|
||||||
|
console.error(`Failed to parse profile for ${pubkey}`);
|
||||||
|
results.set(pubkey, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set null for pubkeys we didn't find
|
||||||
|
for (const pubkey of uncachedPubkeys) {
|
||||||
|
if (!results.has(pubkey)) {
|
||||||
|
results.set(pubkey, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated cache to storage
|
||||||
|
await this.#saveCacheToStorage();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profiles:', error);
|
||||||
|
// Set null for all unfetched pubkeys on error
|
||||||
|
for (const pubkey of uncachedPubkeys) {
|
||||||
|
if (!results.has(pubkey)) {
|
||||||
|
results.set(pubkey, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to fetch a single profile
|
||||||
|
*/
|
||||||
|
async #doFetchProfile(pubkey: string): Promise<ProfileMetadata | null> {
|
||||||
|
const pool = this.#getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await this.#queryWithTimeout(
|
||||||
|
pool,
|
||||||
|
FALLBACK_PROFILE_RELAYS,
|
||||||
|
[{ kinds: [0], authors: [pubkey] }],
|
||||||
|
FETCH_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the most recent event
|
||||||
|
const latestEvent = events.reduce((latest, event) =>
|
||||||
|
event.created_at > latest.created_at ? event : latest
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(latestEvent.content);
|
||||||
|
const profile: ProfileMetadata = {
|
||||||
|
pubkey,
|
||||||
|
name: content.name,
|
||||||
|
display_name: content.display_name,
|
||||||
|
displayName: content.displayName,
|
||||||
|
picture: content.picture,
|
||||||
|
banner: content.banner,
|
||||||
|
about: content.about,
|
||||||
|
website: content.website,
|
||||||
|
nip05: content.nip05,
|
||||||
|
lud06: content.lud06,
|
||||||
|
lud16: content.lud16,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
};
|
||||||
|
this.#cache[pubkey] = profile;
|
||||||
|
|
||||||
|
// Save updated cache to storage
|
||||||
|
await this.#saveCacheToStorage();
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
} catch {
|
||||||
|
console.error(`Failed to parse profile content for ${pubkey}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch profile for ${pubkey}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query relays with a timeout
|
||||||
|
*/
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache
|
||||||
|
*/
|
||||||
|
async clearCache(): Promise<void> {
|
||||||
|
this.#cache = {};
|
||||||
|
await this.#saveCacheToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache for a specific pubkey
|
||||||
|
*/
|
||||||
|
async clearCacheForPubkey(pubkey: string): Promise<void> {
|
||||||
|
delete this.#cache[pubkey];
|
||||||
|
await this.#saveCacheToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display name for a profile (prioritizes display_name over name)
|
||||||
|
*/
|
||||||
|
getDisplayName(profile: ProfileMetadata | null): string | undefined {
|
||||||
|
if (!profile) return undefined;
|
||||||
|
return profile.display_name || profile.displayName || profile.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the username for a profile (prioritizes name over display_name)
|
||||||
|
*/
|
||||||
|
getUsername(profile: ProfileMetadata | null): string | undefined {
|
||||||
|
if (!profile) return undefined;
|
||||||
|
return profile.name || profile.display_name || profile.displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { SimplePool } from 'nostr-tools/pool';
|
||||||
|
import { FALLBACK_PROFILE_RELAYS } from '../../constants/fallback-relays';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
declare const chrome: any;
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
const FETCH_TIMEOUT_MS = 10000; // 10 seconds
|
||||||
|
const STORAGE_KEY = 'relayListCache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-65 Relay List entry
|
||||||
|
*/
|
||||||
|
export interface Nip65Relay {
|
||||||
|
url: string;
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached relay list for a pubkey
|
||||||
|
*/
|
||||||
|
export interface RelayListCache {
|
||||||
|
pubkey: string;
|
||||||
|
relays: Nip65Relay[];
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for relay lists, stored in session storage
|
||||||
|
*/
|
||||||
|
type RelayListCacheMap = Record<string, RelayListCache>;
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class RelayListService {
|
||||||
|
#cache: RelayListCacheMap = {};
|
||||||
|
#pool: SimplePool | null = null;
|
||||||
|
#fetchPromises = new Map<string, Promise<Nip65Relay[]>>();
|
||||||
|
#initialized = false;
|
||||||
|
#initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the service by loading cache from session storage
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.#initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#initPromise) {
|
||||||
|
return this.#initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#initPromise = this.#loadCacheFromStorage();
|
||||||
|
await this.#initPromise;
|
||||||
|
this.#initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cache from browser session storage
|
||||||
|
*/
|
||||||
|
async #loadCacheFromStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
|
||||||
|
const result = await chrome.storage.session.get(STORAGE_KEY);
|
||||||
|
if (result[STORAGE_KEY]) {
|
||||||
|
this.#cache = result[STORAGE_KEY];
|
||||||
|
this.#pruneStaleCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load relay list cache from storage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save cache to browser session storage
|
||||||
|
*/
|
||||||
|
async #saveCacheToStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
|
||||||
|
await chrome.storage.session.set({ [STORAGE_KEY]: this.#cache });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save relay list cache to storage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove stale entries from cache
|
||||||
|
*/
|
||||||
|
#pruneStaleCache(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const pubkey of Object.keys(this.#cache)) {
|
||||||
|
if (now - this.#cache[pubkey].fetchedAt > CACHE_TTL_MS) {
|
||||||
|
delete this.#cache[pubkey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the SimplePool instance, creating it if necessary
|
||||||
|
*/
|
||||||
|
#getPool(): SimplePool {
|
||||||
|
if (!this.#pool) {
|
||||||
|
this.#pool = new SimplePool();
|
||||||
|
}
|
||||||
|
return this.#pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached relay list for a pubkey
|
||||||
|
*/
|
||||||
|
getCachedRelayList(pubkey: string): Nip65Relay[] | null {
|
||||||
|
const cached = this.#cache[pubkey];
|
||||||
|
if (!cached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) {
|
||||||
|
delete this.#cache[pubkey];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.relays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch NIP-65 relay list for a single pubkey
|
||||||
|
*/
|
||||||
|
async fetchRelayList(pubkey: string): Promise<Nip65Relay[]> {
|
||||||
|
await this.initialize();
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.getCachedRelayList(pubkey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already fetching
|
||||||
|
const existingPromise = this.#fetchPromises.get(pubkey);
|
||||||
|
if (existingPromise) {
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new fetch
|
||||||
|
const fetchPromise = this.#doFetchRelayList(pubkey);
|
||||||
|
this.#fetchPromises.set(pubkey, fetchPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchPromise;
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this.#fetchPromises.delete(pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to fetch a single relay list
|
||||||
|
*/
|
||||||
|
async #doFetchRelayList(pubkey: string): Promise<Nip65Relay[]> {
|
||||||
|
const pool = this.#getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await this.#queryWithTimeout(
|
||||||
|
pool,
|
||||||
|
FALLBACK_PROFILE_RELAYS,
|
||||||
|
[{ kinds: [10002], authors: [pubkey] }],
|
||||||
|
FETCH_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the most recent event (kind 10002 is replaceable)
|
||||||
|
const latestEvent = events.reduce((latest, event) =>
|
||||||
|
event.created_at > latest.created_at ? event : latest
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse relay tags
|
||||||
|
const relays: Nip65Relay[] = [];
|
||||||
|
for (const tag of latestEvent.tags) {
|
||||||
|
if (tag[0] === 'r' && tag[1]) {
|
||||||
|
const url = tag[1];
|
||||||
|
const marker = tag[2]; // Optional: "read" or "write"
|
||||||
|
|
||||||
|
let read = true;
|
||||||
|
let write = true;
|
||||||
|
|
||||||
|
if (marker === 'read') {
|
||||||
|
write = false;
|
||||||
|
} else if (marker === 'write') {
|
||||||
|
read = false;
|
||||||
|
}
|
||||||
|
// No marker means both read and write
|
||||||
|
|
||||||
|
relays.push({ url, read, write });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
this.#cache[pubkey] = {
|
||||||
|
pubkey,
|
||||||
|
relays,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await this.#saveCacheToStorage();
|
||||||
|
|
||||||
|
return relays;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch relay list for ${pubkey}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query relays with a timeout
|
||||||
|
*/
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache
|
||||||
|
*/
|
||||||
|
async clearCache(): Promise<void> {
|
||||||
|
this.#cache = {};
|
||||||
|
await this.#saveCacheToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache for a specific pubkey
|
||||||
|
*/
|
||||||
|
async clearCacheForPubkey(pubkey: string): Promise<void> {
|
||||||
|
delete this.#cache[pubkey];
|
||||||
|
await this.#saveCacheToStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,3 +93,26 @@ export interface SignerMetaData {
|
|||||||
|
|
||||||
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
|
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached profile metadata from kind 0 events
|
||||||
|
*/
|
||||||
|
export interface ProfileMetadata {
|
||||||
|
pubkey: string;
|
||||||
|
name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
displayName?: string; // Some clients use this instead
|
||||||
|
picture?: string;
|
||||||
|
banner?: string;
|
||||||
|
about?: string;
|
||||||
|
website?: string;
|
||||||
|
nip05?: string;
|
||||||
|
lud06?: string;
|
||||||
|
lud16?: string;
|
||||||
|
fetchedAt: number; // Timestamp when this was fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for profile metadata, stored in session storage
|
||||||
|
*/
|
||||||
|
export type ProfileMetadataCache = Record<string, ProfileMetadata>;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 20px;
|
font-family: var(--font-heading);
|
||||||
font-weight: 500;
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,70 +17,130 @@
|
|||||||
--font-heading: 'reglisse', sans-serif;
|
--font-heading: 'reglisse', sans-serif;
|
||||||
--font-theylive: 'theylive', sans-serif;
|
--font-theylive: 'theylive', sans-serif;
|
||||||
|
|
||||||
// Background colors (dark theme based on market)
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--background-light: #131313;
|
|
||||||
--background-light-hover: #1d1d1d;
|
|
||||||
--foreground: #fafafa;
|
|
||||||
|
|
||||||
// Border colors (adapted from market --border: #dac8d3)
|
|
||||||
--border: #3d3d3d;
|
|
||||||
--border-light: #4d4d4d;
|
|
||||||
|
|
||||||
// Primary colors (dark buttons like market, inverted for dark theme)
|
|
||||||
--primary: #fafafa;
|
|
||||||
--primary-hover: #ff3eb5;
|
|
||||||
--primary-foreground: #1d1d1d;
|
|
||||||
--primary-foreground-hover: #ff3eb5;
|
|
||||||
--primary-border: #fafafa;
|
|
||||||
--primary-border-hover: #ff3eb5;
|
|
||||||
|
|
||||||
// Secondary colors (pink accent - the main brand color from market)
|
|
||||||
--secondary: #ff3eb5;
|
|
||||||
--secondary-hover: #0a0a0a;
|
|
||||||
--secondary-foreground: #0a0a0a;
|
|
||||||
--secondary-foreground-hover: #ff3eb5;
|
|
||||||
--secondary-border: #ff3eb5;
|
|
||||||
--secondary-border-hover: #ff3eb5;
|
|
||||||
|
|
||||||
// Focus colors (yellow from market)
|
|
||||||
--focus: #ffd53d;
|
|
||||||
--focus-hover: #0a0a0a;
|
|
||||||
--focus-foreground: #0a0a0a;
|
|
||||||
--focus-foreground-hover: #ffd53d;
|
|
||||||
--focus-border: #ffd53d;
|
|
||||||
--focus-border-hover: #ffd53d;
|
|
||||||
|
|
||||||
// Muted colors
|
|
||||||
--muted: #1d1d1d;
|
|
||||||
--muted-foreground: #666666;
|
|
||||||
|
|
||||||
// Accent colors
|
|
||||||
--accent: #2a2a2a;
|
|
||||||
--accent-foreground: #fafafa;
|
|
||||||
|
|
||||||
// Destructive colors
|
|
||||||
--destructive: #bf4040;
|
|
||||||
--destructive-foreground: #fafafa;
|
|
||||||
|
|
||||||
// Additional brand colors (from market)
|
|
||||||
--off-black: #0a0a0a;
|
|
||||||
--neo-purple: #ff3eb5;
|
|
||||||
--secondary-black: #131313;
|
|
||||||
--tertiary-black: #1d1d1d;
|
|
||||||
--neo-blue: #18b9fe;
|
|
||||||
--neo-yellow: #ffd53d;
|
|
||||||
--light-gray: #ebebeb;
|
|
||||||
--neo-gray: #f8f8f8;
|
|
||||||
|
|
||||||
// Ring color for focus states
|
|
||||||
--ring: #ff3eb5;
|
|
||||||
--input: #996685;
|
|
||||||
|
|
||||||
// Border radius (from market)
|
// Border radius (from market)
|
||||||
--radius: 0.25rem;
|
--radius: 0.25rem;
|
||||||
--radius-sm: 2px;
|
--radius-sm: 2px;
|
||||||
--radius-md: 4px;
|
--radius-md: 4px;
|
||||||
--radius-lg: 8px;
|
--radius-lg: 8px;
|
||||||
--radius-xl: 24px;
|
--radius-xl: 24px;
|
||||||
|
|
||||||
|
// Primary colors - Pink action buttons (Plebeian Market style)
|
||||||
|
--primary: #ff3eb5;
|
||||||
|
--primary-hover: #e6359f;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--primary-foreground-hover: #ffffff;
|
||||||
|
--primary-border: #ff3eb5;
|
||||||
|
--primary-border-hover: #e6359f;
|
||||||
|
|
||||||
|
// Secondary colors (pink accent)
|
||||||
|
--secondary: #ff3eb5;
|
||||||
|
--secondary-border: #ff3eb5;
|
||||||
|
--secondary-border-hover: #ff3eb5;
|
||||||
|
|
||||||
|
// Focus colors (yellow from market)
|
||||||
|
--focus: #ffd53d;
|
||||||
|
--focus-border: #ffd53d;
|
||||||
|
--focus-border-hover: #ffd53d;
|
||||||
|
|
||||||
|
// Brand colors
|
||||||
|
--neo-purple: #ff3eb5;
|
||||||
|
--neo-blue: #18b9fe;
|
||||||
|
--neo-yellow: #ffd53d;
|
||||||
|
|
||||||
|
// Ring color for focus states
|
||||||
|
--ring: #ff3eb5;
|
||||||
|
|
||||||
|
// Destructive colors
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DARK THEME (default)
|
||||||
|
// ============================================
|
||||||
|
:root {
|
||||||
|
// Background colors
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--background-light: #131313;
|
||||||
|
--background-light-hover: #1d1d1d;
|
||||||
|
--foreground: #fafafa;
|
||||||
|
|
||||||
|
// Border colors
|
||||||
|
--border: #3d3d3d;
|
||||||
|
--border-light: #4d4d4d;
|
||||||
|
|
||||||
|
// Secondary theme-dependent colors
|
||||||
|
--secondary-hover: #0a0a0a;
|
||||||
|
--secondary-foreground: #0a0a0a;
|
||||||
|
--secondary-foreground-hover: #ff3eb5;
|
||||||
|
|
||||||
|
// Focus theme-dependent colors
|
||||||
|
--focus-hover: #0a0a0a;
|
||||||
|
--focus-foreground: #0a0a0a;
|
||||||
|
--focus-foreground-hover: #ffd53d;
|
||||||
|
|
||||||
|
// Muted colors
|
||||||
|
--muted: #1d1d1d;
|
||||||
|
--muted-foreground: #a1a1a1;
|
||||||
|
|
||||||
|
// Accent colors
|
||||||
|
--accent: #2a2a2a;
|
||||||
|
--accent-foreground: #fafafa;
|
||||||
|
|
||||||
|
// Input colors
|
||||||
|
--input: #2a2a2a;
|
||||||
|
--input-border: #3d3d3d;
|
||||||
|
|
||||||
|
// Additional brand colors
|
||||||
|
--off-black: #0a0a0a;
|
||||||
|
--secondary-black: #131313;
|
||||||
|
--tertiary-black: #1d1d1d;
|
||||||
|
--light-gray: #ebebeb;
|
||||||
|
--neo-gray: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LIGHT THEME (follows browser preference)
|
||||||
|
// ============================================
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
// Background colors
|
||||||
|
--background: #ffffff;
|
||||||
|
--background-light: #f5f5f5;
|
||||||
|
--background-light-hover: #ebebeb;
|
||||||
|
--foreground: #0a0a0a;
|
||||||
|
|
||||||
|
// Border colors
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--border-light: #d0d0d0;
|
||||||
|
|
||||||
|
// Secondary theme-dependent colors
|
||||||
|
--secondary-hover: #fce7f3;
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--secondary-foreground-hover: #ff3eb5;
|
||||||
|
|
||||||
|
// Focus theme-dependent colors
|
||||||
|
--focus-hover: #fef9c3;
|
||||||
|
--focus-foreground: #0a0a0a;
|
||||||
|
--focus-foreground-hover: #ca8a04;
|
||||||
|
|
||||||
|
// Muted colors
|
||||||
|
--muted: #f5f5f5;
|
||||||
|
--muted-foreground: #737373;
|
||||||
|
|
||||||
|
// Accent colors
|
||||||
|
--accent: #f5f5f5;
|
||||||
|
--accent-foreground: #0a0a0a;
|
||||||
|
|
||||||
|
// Input colors
|
||||||
|
--input: #ffffff;
|
||||||
|
--input-border: #d0d0d0;
|
||||||
|
|
||||||
|
// Additional brand colors (inverted for light)
|
||||||
|
--off-black: #ffffff;
|
||||||
|
--secondary-black: #f5f5f5;
|
||||||
|
--tertiary-black: #ebebeb;
|
||||||
|
--light-gray: #404040;
|
||||||
|
--neo-gray: #262626;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
// Common
|
// Common
|
||||||
export * from './lib/common/nav-component';
|
export * from './lib/common/nav-component';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export * from './lib/constants/fallback-relays';
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export * from './lib/helpers/crypto-helper';
|
export * from './lib/helpers/crypto-helper';
|
||||||
export * from './lib/helpers/nostr-helper';
|
export * from './lib/helpers/nostr-helper';
|
||||||
@@ -22,6 +25,8 @@ export * from './lib/services/storage/browser-session-handler';
|
|||||||
export * from './lib/services/storage/signer-meta-handler';
|
export * from './lib/services/storage/signer-meta-handler';
|
||||||
export * from './lib/services/logger/logger.service';
|
export * from './lib/services/logger/logger.service';
|
||||||
export * from './lib/services/startup/startup.service';
|
export * from './lib/services/startup/startup.service';
|
||||||
|
export * from './lib/services/profile-metadata/profile-metadata.service';
|
||||||
|
export * from './lib/services/relay-list/relay-list.service';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
export * from './lib/components/icon-button/icon-button.component';
|
export * from './lib/components/icon-button/icon-button.component';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"inlineSources": true,
|
"inlineSources": true,
|
||||||
"types": []
|
"types": ["chrome"]
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"**/*.spec.ts"
|
"**/*.spec.ts"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Plebeian Signer",
|
"name": "Plebeian Signer",
|
||||||
"description": "Nostr Identity Manager & Signer",
|
"description": "Nostr Identity Manager & Signer",
|
||||||
"version": "0.0.5",
|
"version": "0.0.6",
|
||||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
||||||
<html data-bs-theme="dark">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Plebeian Signer - Options</title>
|
<title>Plebeian Signer - Options</title>
|
||||||
<link rel="stylesheet" type="text/css" href="styles.css" />
|
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||||
<script src="scripts.js"></script>
|
<script src="scripts.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
/* Prevent white flash on load */
|
||||||
|
html { background-color: #0a0a0a; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html { background-color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
color: #ffffff;
|
color: var(--foreground);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
||||||
<html data-bs-theme="dark">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Plebeian Signer</title>
|
<title>Plebeian Signer</title>
|
||||||
<link rel="stylesheet" type="text/css" href="styles.css" />
|
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||||
<script src="scripts.js"></script>
|
<script src="scripts.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
/* Prevent white flash on load */
|
||||||
|
html { background-color: #0a0a0a; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html { background-color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
color: #ffffff;
|
color: var(--foreground);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +36,7 @@
|
|||||||
padding: var(--size);
|
padding: var(--size);
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #ffffff;
|
color: var(--foreground);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,16 @@
|
|||||||
<div class="sam-flex-row gap-h">
|
<div class="sam-flex-row gap-h">
|
||||||
<lib-relay-rw
|
<lib-relay-rw
|
||||||
type="read"
|
type="read"
|
||||||
[(model)]="relay.read"
|
[model]="relay.read"
|
||||||
(modelChange)="onRelayChanged(relay)"
|
[readonly]="true"
|
||||||
></lib-relay-rw>
|
></lib-relay-rw>
|
||||||
<lib-relay-rw
|
<lib-relay-rw
|
||||||
type="write"
|
type="write"
|
||||||
[(model)]="relay.write"
|
[model]="relay.write"
|
||||||
(modelChange)="onRelayChanged(relay)"
|
[readonly]="true"
|
||||||
></lib-relay-rw>
|
></lib-relay-rw>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<lib-icon-button
|
|
||||||
icon="trash"
|
|
||||||
title="Remove relay"
|
|
||||||
(click)="onClickRemoveRelay(relay)"
|
|
||||||
style="margin-top: 4px"
|
|
||||||
></lib-icon-button>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
@@ -31,48 +24,37 @@
|
|||||||
icon="chevron-left"
|
icon="chevron-left"
|
||||||
(click)="navigateBack()"
|
(click)="navigateBack()"
|
||||||
></lib-icon-button>
|
></lib-icon-button>
|
||||||
<span>Relays</span>
|
<span class="header-title">Relays</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sam-mb-2 sam-flex-row gap">
|
<div class="info-banner">
|
||||||
<div class="sam-flex-column sam-flex-grow">
|
<i class="bi bi-info-circle"></i>
|
||||||
<input
|
<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>
|
||||||
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>
|
</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
|
<ng-container
|
||||||
*ngTemplateOutlet="relayTemplate; context: { relay: relay }"
|
*ngTemplateOutlet="relayTemplate; context: { relay: relay }"
|
||||||
></ng-container>
|
></ng-container>
|
||||||
|
|||||||
@@ -17,14 +17,81 @@
|
|||||||
top: 0;
|
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 {
|
.relay {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
padding: 4px 8px 6px 8px;
|
padding: 4px 8px 6px 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--background-light-hover);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
import { NgTemplateOutlet } from '@angular/common';
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { Component, inject, OnInit } from '@angular/core';
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
IconButtonComponent,
|
IconButtonComponent,
|
||||||
Identity_DECRYPTED,
|
Identity_DECRYPTED,
|
||||||
NavComponent,
|
NavComponent,
|
||||||
Relay_DECRYPTED,
|
Nip65Relay,
|
||||||
|
NostrHelper,
|
||||||
|
RelayListService,
|
||||||
RelayRwComponent,
|
RelayRwComponent,
|
||||||
StorageService,
|
StorageService,
|
||||||
VisualRelayPipe,
|
VisualRelayPipe,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
|
|
||||||
interface NewRelay {
|
|
||||||
url: string;
|
|
||||||
read: boolean;
|
|
||||||
write: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-relays',
|
selector: 'app-relays',
|
||||||
imports: [
|
imports: [
|
||||||
IconButtonComponent,
|
IconButtonComponent,
|
||||||
FormsModule,
|
|
||||||
RelayRwComponent,
|
RelayRwComponent,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
VisualRelayPipe,
|
VisualRelayPipe,
|
||||||
@@ -32,100 +26,52 @@ interface NewRelay {
|
|||||||
})
|
})
|
||||||
export class RelaysComponent extends NavComponent implements OnInit {
|
export class RelaysComponent extends NavComponent implements OnInit {
|
||||||
identity?: Identity_DECRYPTED;
|
identity?: Identity_DECRYPTED;
|
||||||
relays: Relay_DECRYPTED[] = [];
|
relays: Nip65Relay[] = [];
|
||||||
addRelayInputHasFocus = false;
|
loading = true;
|
||||||
newRelay: NewRelay = {
|
errorMessage = '';
|
||||||
url: '',
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
};
|
|
||||||
canAdd = false;
|
|
||||||
|
|
||||||
readonly #activatedRoute = inject(ActivatedRoute);
|
readonly #activatedRoute = inject(ActivatedRoute);
|
||||||
readonly #storage = inject(StorageService);
|
readonly #storage = inject(StorageService);
|
||||||
|
readonly #relayListService = inject(RelayListService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const selectedIdentityId =
|
const selectedIdentityId =
|
||||||
this.#activatedRoute.parent?.snapshot.params['id'];
|
this.#activatedRoute.parent?.snapshot.params['id'];
|
||||||
if (!selectedIdentityId) {
|
if (!selectedIdentityId) {
|
||||||
|
this.loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#loadData(selectedIdentityId);
|
this.#loadData(selectedIdentityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluateCanAdd() {
|
async #loadData(identityId: string) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.#storage.deleteRelay(relay.id);
|
this.loading = true;
|
||||||
this.#loadData(this.identity.id);
|
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) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.error('Failed to load relay list:', error);
|
||||||
// TODO
|
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>
|
<span>You</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vertically-centered">
|
<div class="identity-container">
|
||||||
<div class="sam-flex-column center">
|
<!-- Banner background -->
|
||||||
<div class="sam-flex-column gap center">
|
<div
|
||||||
<div class="picture-frame" [class.padding]="!loadedData.profile?.image">
|
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
|
<img
|
||||||
[src]="
|
[src]="avatarUrl || 'person-fill.svg'"
|
||||||
!loadedData.profile?.image
|
|
||||||
? 'person-fill.svg'
|
|
||||||
: loadedData.profile?.image
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
|
class="avatar-image"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Display name (primary, large) -->
|
||||||
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
||||||
<span class="name" (click)="onClickShowDetails()">
|
<div class="name-badge-container" (click)="onClickShowDetails()">
|
||||||
{{ selectedIdentity?.nick }}
|
<span class="display-name">
|
||||||
</span>
|
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
||||||
|
</span>
|
||||||
|
@if(username) {
|
||||||
|
<span class="username">
|
||||||
|
{{ username }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@if(loadedData.profile) {
|
<!-- NIP-05 verification -->
|
||||||
<div class="sam-flex-row gap-h">
|
@if(profile?.nip05) {
|
||||||
@if(loadedData.validating) {
|
<div class="nip05-row">
|
||||||
|
@if(validating) {
|
||||||
<i class="bi bi-circle color-activity"></i>
|
<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>
|
<i class="bi bi-patch-check sam-color-primary"></i>
|
||||||
} @else {
|
} @else {
|
||||||
<i class="bi bi-exclamation-octagon-fill sam-color-danger"></i>
|
<i class="bi bi-exclamation-octagon-fill sam-color-danger"></i>
|
||||||
} }
|
} }
|
||||||
|
|
||||||
<span class="sam-color-primary">{{
|
<span class="nip05-badge">{{
|
||||||
loadedData.profile.nip05 | visualNip05
|
profile?.nip05 | visualNip05
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
|
||||||
<span> </span>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<lib-pubkey
|
<!-- npub display -->
|
||||||
[value]="selectedIdentityNpub ?? 'na'"
|
<div class="npub-wrapper">
|
||||||
[first]="14"
|
<lib-pubkey
|
||||||
[last]="8"
|
[value]="selectedIdentityNpub ?? 'na'"
|
||||||
(click)="
|
[first]="14"
|
||||||
copyToClipboard(selectedIdentityNpub);
|
[last]="8"
|
||||||
toast.show('Copied to clipboard')
|
(click)="
|
||||||
"
|
copyToClipboard(selectedIdentityNpub);
|
||||||
></lib-pubkey>
|
toast.show('Copied to clipboard')
|
||||||
|
"
|
||||||
|
></lib-pubkey>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,39 +3,162 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.vertically-centered {
|
.identity-container {
|
||||||
height: 100%;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-background {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: 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 {
|
.banner-overlay {
|
||||||
font-size: 20px;
|
position: absolute;
|
||||||
font-weight: 500;
|
top: 0;
|
||||||
cursor: pointer;
|
left: 0;
|
||||||
max-width: 343px;
|
right: 0;
|
||||||
overflow-x: hidden;
|
bottom: 0;
|
||||||
text-overflow: ellipsis;
|
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;
|
height: 120px;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
border: 2px solid white;
|
border: 3px solid rgba(255, 255, 255, 0.9);
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
&.padding {
|
background: var(--background);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
&.has-image {
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
.avatar-image {
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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-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 {
|
import {
|
||||||
Identity_DECRYPTED,
|
Identity_DECRYPTED,
|
||||||
NostrHelper,
|
NostrHelper,
|
||||||
|
ProfileMetadata,
|
||||||
|
ProfileMetadataService,
|
||||||
PubkeyComponent,
|
PubkeyComponent,
|
||||||
StorageService,
|
StorageService,
|
||||||
ToastComponent,
|
ToastComponent,
|
||||||
VisualNip05Pipe,
|
VisualNip05Pipe,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import NDK, { NDKUserProfile } from '@nostr-dev-kit/ndk';
|
import NDK from '@nostr-dev-kit/ndk';
|
||||||
|
|
||||||
interface LoadedData {
|
|
||||||
profile: NDKUserProfile | undefined;
|
|
||||||
nip05: string | undefined;
|
|
||||||
nip05isValidated: boolean | undefined;
|
|
||||||
validating: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-identity',
|
selector: 'app-identity',
|
||||||
@@ -26,20 +21,35 @@ interface LoadedData {
|
|||||||
export class IdentityComponent implements OnInit {
|
export class IdentityComponent implements OnInit {
|
||||||
selectedIdentity: Identity_DECRYPTED | undefined;
|
selectedIdentity: Identity_DECRYPTED | undefined;
|
||||||
selectedIdentityNpub: string | undefined;
|
selectedIdentityNpub: string | undefined;
|
||||||
loadedData: LoadedData = {
|
profile: ProfileMetadata | null = null;
|
||||||
profile: undefined,
|
nip05isValidated: boolean | undefined;
|
||||||
nip05: undefined,
|
validating = false;
|
||||||
nip05isValidated: undefined,
|
loading = true;
|
||||||
validating: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
readonly #storage = inject(StorageService);
|
readonly #storage = inject(StorageService);
|
||||||
readonly #router = inject(Router);
|
readonly #router = inject(Router);
|
||||||
|
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.#loadData();
|
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) {
|
copyToClipboard(pubkey: string | undefined) {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
return;
|
return;
|
||||||
@@ -70,6 +80,7 @@ export class IdentityComponent implements OnInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
|
this.loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,41 +88,66 @@ export class IdentityComponent implements OnInit {
|
|||||||
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
||||||
this.selectedIdentityNpub = NostrHelper.pubkey2npub(pubkey);
|
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 =
|
const relays =
|
||||||
this.#storage
|
this.#storage
|
||||||
.getBrowserSessionHandler()
|
.getBrowserSessionHandler()
|
||||||
.browserSessionData?.relays.filter(
|
.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);
|
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
|
||||||
|
|
||||||
// Fetch the user's profile.
|
if (relevantRelays.length > 0) {
|
||||||
const ndk = new NDK({
|
const ndk = new NDK({
|
||||||
explicitRelayUrls: relevantRelays,
|
explicitRelayUrls: relevantRelays,
|
||||||
});
|
});
|
||||||
|
await ndk.connect();
|
||||||
await ndk.connect();
|
const user = ndk.getUser({ pubkey });
|
||||||
|
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.validating = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error('NIP-05 validation failed:', error);
|
||||||
// TODO
|
this.validating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
placeholder="vault password"
|
placeholder="vault password"
|
||||||
[(ngModel)]="loginPassword"
|
[(ngModel)]="loginPassword"
|
||||||
(keyup.enter)="loginPassword && loginVault()"
|
(keyup.enter)="loginPassword && loginVault()"
|
||||||
|
autofocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
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';
|
import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-service-config';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -17,6 +23,7 @@ export class VaultLoginComponent {
|
|||||||
readonly #storage = inject(StorageService);
|
readonly #storage = inject(StorageService);
|
||||||
readonly #router = inject(Router);
|
readonly #router = inject(Router);
|
||||||
readonly #startup = inject(StartupService);
|
readonly #startup = inject(StartupService);
|
||||||
|
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||||
|
|
||||||
toggleType(element: HTMLInputElement) {
|
toggleType(element: HTMLInputElement) {
|
||||||
if (element.type === 'password') {
|
if (element.type === 'password') {
|
||||||
@@ -33,7 +40,11 @@ export class VaultLoginComponent {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.#storage.unlockVault(this.loginPassword);
|
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) {
|
} catch (error) {
|
||||||
this.showInvalidPasswordAlert = true;
|
this.showInvalidPasswordAlert = true;
|
||||||
console.log(error);
|
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() {
|
async onClickResetExtension() {
|
||||||
try {
|
try {
|
||||||
await this.#storage.resetExtension();
|
await this.#storage.resetExtension();
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-bs-theme="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Plebeian Signer</title>
|
<title>Plebeian Signer</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ import { Nip07Method } from '@common';
|
|||||||
|
|
||||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
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 {
|
class Messenger {
|
||||||
#requests = new Map<
|
#requests = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -18,7 +32,7 @@ class Messenger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async request(method: Nip07Method, params: any): Promise<any> {
|
async request(method: Nip07Method, params: any): Promise<any> {
|
||||||
const id = crypto.randomUUID();
|
const id = generateUUID();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.#requests.set(id, { resolve, reject });
|
this.#requests.set(id, { resolve, reject });
|
||||||
|
|||||||
@@ -25,3 +25,79 @@ button {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-family: var(--font-sans);
|
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