Release v0.0.7 - Identity list UI redesign with avatars

- Redesign identities list to show avatar and display name from Nostr profile
- Replace star icon selection with clickable row for identity switching
- Add gear icon for direct access to identity settings
- Highlight selected identity with primary color border
- Remove credits box from information tab
- Add rebuild instruction to CLAUDE.md
- Clean up unused imports in info components
- Replace HTML autofocus with programmatic focus for accessibility

Files modified:
- CLAUDE.md
- package.json
- projects/chrome/src/app/components/home/identities/*
- projects/chrome/src/app/components/home/info/*
- projects/chrome/src/app/components/vault-login/*
- projects/firefox/src/app/components/home/identities/*
- projects/firefox/src/app/components/home/info/*
- projects/firefox/src/app/components/vault-login/*

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-19 09:07:36 +01:00
parent 578f3e08ff
commit 1491ac13af
18 changed files with 208 additions and 191 deletions

View File

@@ -18,6 +18,11 @@ npm test # Run unit tests with Karma
npm run lint # Run ESLint npm run lint # Run ESLint
``` ```
**Important:** After making any code changes, rebuild both extensions before testing:
```bash
npm run build:chrome && npm run build:firefox
```
## Architecture ## Architecture
### Monorepo Structure ### Monorepo Structure

View File

@@ -1,12 +1,12 @@
{ {
"name": "plebeian-signer", "name": "plebeian-signer",
"version": "0.0.6", "version": "0.0.7",
"custom": { "custom": {
"chrome": { "chrome": {
"version": "0.0.6" "version": "0.0.7"
}, },
"firefox": { "firefox": {
"version": "0.0.6" "version": "0.0.7"
} }
}, },
"scripts": { "scripts": {

View File

@@ -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.6", "version": "0.0.7",
"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": [

View File

@@ -12,65 +12,35 @@
</div> </div>
@let sessionData = storage.getBrowserSessionHandler().browserSessionData; @let sessionData = storage.getBrowserSessionHandler().browserSessionData;
<!-- - --> @let identities = sessionData?.identities ?? [];
@let identities = sessionData?.identities ?? []; @if(identities.length === 0) {
<div @if(identities.length === 0) {
style=" <div class="empty-state">
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
"
>
<span class="sam-text-muted"> <span class="sam-text-muted">
Create your first identity by clicking on the button in the upper right Create your first identity by clicking on the button in the upper right
corner. corner.
</span> </span>
</div> </div>
}
} @for(identity of identities; track identity) { @for(identity of identities; track identity.id) {
@let isSelected = identity.id === sessionData?.selectedIdentityId;
<div <div
class="identity" class="identity"
style="overflow: hidden" [class.selected]="isSelected"
(click)="onClickEditIdentity(identity)" (click)="onClickSelectIdentity(identity.id)"
> >
@let isSelected = identity.id === sessionData?.selectedIdentityId; <img
class="avatar"
<span [src]="getAvatarUrl(identity)"
class="no-select" alt=""
style="overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap" (error)="$any($event.target).src = 'assets/person-fill.svg'"
[class.not-active]="!isSelected" />
> <span class="name">{{ getDisplayName(identity) }}</span>
{{ identity.nick }}
</span>
<div class="sam-flex-grow"></div>
@if(isSelected) {
<lib-icon-button <lib-icon-button
icon="star-fill" icon="gear"
title="Edit identity" title="Identity settings"
style="pointer-events: none; color: var(--bs-pink)" (click)="onClickEditIdentity(identity.id, $event)"
></lib-icon-button>
}
<div class="buttons sam-flex-row gap-h">
@if(!isSelected) {
<lib-icon-button
icon="star-fill"
title="Select identity"
(click)="
onClickSwitchIdentity(identity.id, $event);
toast.show('Identity changed')
"
></lib-icon-button>
}
</div>
<lib-icon-button
icon="arrow-right"
title="Edit identity"
style="pointer-events: none"
></lib-icon-button> ></lib-icon-button>
</div> </div>
} }

View File

@@ -35,34 +35,53 @@
} }
} }
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.identity { .identity {
height: 48px; height: 56px;
min-height: 48px; min-height: 56px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding-left: 16px; gap: 12px;
padding-left: 12px;
padding-right: 8px; padding-right: 8px;
background: var(--background-light); background: var(--background-light);
border-radius: 8px; border-radius: 8px;
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease;
.not-active {
//color: #525b6a;
opacity: 0.4;
}
&:hover { &:hover {
background: var(--background-light-hover); background: var(--background-light-hover);
.buttons {
visibility: visible;
}
} }
.buttons { &.selected {
visibility: hidden; background: rgba(255, 62, 181, 0.15);
border: 1px solid var(--primary);
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--muted);
}
.name {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
} }
} }
} }

View File

@@ -1,8 +1,11 @@
import { Component, inject } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { import {
IconButtonComponent, IconButtonComponent,
Identity_DECRYPTED, Identity_DECRYPTED,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
StorageService, StorageService,
ToastComponent, ToastComponent,
} from '@common'; } from '@common';
@@ -13,21 +16,48 @@ import {
styleUrl: './identities.component.scss', styleUrl: './identities.component.scss',
imports: [IconButtonComponent, ToastComponent], imports: [IconButtonComponent, ToastComponent],
}) })
export class IdentitiesComponent { export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService); readonly storage = inject(StorageService);
readonly #router = inject(Router); readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
}
#loadProfiles() {
const identities = this.storage.getBrowserSessionHandler().browserSessionData?.identities ?? [];
for (const identity of identities) {
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
const profile = this.#profileMetadata.getCachedProfile(pubkey);
this.#profileCache.set(identity.id, profile);
}
}
getAvatarUrl(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id);
return profile?.picture || 'assets/person-fill.svg';
}
getDisplayName(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id) ?? null;
return this.#profileMetadata.getDisplayName(profile) || identity.nick;
}
onClickNewIdentity() { onClickNewIdentity() {
this.#router.navigateByUrl('/new-identity'); this.#router.navigateByUrl('/new-identity');
} }
onClickEditIdentity(identity: Identity_DECRYPTED) { onClickEditIdentity(identityId: string, event: MouseEvent) {
this.#router.navigateByUrl(`/edit-identity/${identity.id}/home`); event.stopPropagation();
this.#router.navigateByUrl(`/edit-identity/${identityId}/home`);
} }
async onClickSwitchIdentity(identityId: string, event: MouseEvent) { async onClickSelectIdentity(identityId: string) {
event.stopPropagation();
await this.storage.switchIdentity(identityId); await this.storage.switchIdentity(identityId);
} }
} }

View File

@@ -14,19 +14,3 @@
git.mleku.dev/mleku/plebeian-signer git.mleku.dev/mleku/plebeian-signer
</a> </a>
<div class="sam-flex-grow"></div>
<div class="sam-card sam-mb" style="align-items: center">
<span>
Made with <i class="bi bi-heart-fill" style="color: red"></i> by
<a href="https://sam-hayes.org" target="_blank">Sam Hayes</a>
</span>
<lib-pubkey
class="sam-mt-h"
value="npub1tgyjshvelwj73t3jy0n3xllgt03elkapfl3k3n0x2wkunegkgrwssfp0u4"
(click)="toast.show('Copied to clipboard')"
></lib-pubkey>
</div>
<lib-toast #toast [bottom]="188"></lib-toast>

View File

@@ -1,10 +1,8 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PubkeyComponent, ToastComponent } from '@common';
import packageJson from '../../../../../../../package.json'; import packageJson from '../../../../../../../package.json';
@Component({ @Component({
selector: 'app-info', selector: 'app-info',
imports: [PubkeyComponent, ToastComponent],
templateUrl: './info.component.html', templateUrl: './info.component.html',
styleUrl: './info.component.scss', styleUrl: './info.component.scss',
}) })

View File

@@ -16,7 +16,6 @@
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"

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { AfterViewInit, Component, ElementRef, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { import {
@@ -16,7 +16,9 @@ import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-se
styleUrl: './vault-login.component.scss', styleUrl: './vault-login.component.scss',
imports: [FormsModule, ConfirmComponent], imports: [FormsModule, ConfirmComponent],
}) })
export class VaultLoginComponent { export class VaultLoginComponent implements AfterViewInit {
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
loginPassword = ''; loginPassword = '';
showInvalidPasswordAlert = false; showInvalidPasswordAlert = false;
@@ -25,6 +27,10 @@ export class VaultLoginComponent {
readonly #startup = inject(StartupService); readonly #startup = inject(StartupService);
readonly #profileMetadata = inject(ProfileMetadataService); readonly #profileMetadata = inject(ProfileMetadataService);
ngAfterViewInit() {
this.passwordInput.nativeElement.focus();
}
toggleType(element: HTMLInputElement) { toggleType(element: HTMLInputElement) {
if (element.type === 'password') { if (element.type === 'password') {
element.type = 'text'; element.type = 'text';

View File

@@ -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.6", "version": "0.0.7",
"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": [

View File

@@ -12,65 +12,35 @@
</div> </div>
@let sessionData = storage.getBrowserSessionHandler().browserSessionData; @let sessionData = storage.getBrowserSessionHandler().browserSessionData;
<!-- - --> @let identities = sessionData?.identities ?? [];
@let identities = sessionData?.identities ?? []; @if(identities.length === 0) {
<div @if(identities.length === 0) {
style=" <div class="empty-state">
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
"
>
<span class="sam-text-muted"> <span class="sam-text-muted">
Create your first identity by clicking on the button in the upper right Create your first identity by clicking on the button in the upper right
corner. corner.
</span> </span>
</div> </div>
}
} @for(identity of identities; track identity) { @for(identity of identities; track identity.id) {
@let isSelected = identity.id === sessionData?.selectedIdentityId;
<div <div
class="identity" class="identity"
style="overflow: hidden" [class.selected]="isSelected"
(click)="onClickEditIdentity(identity)" (click)="onClickSelectIdentity(identity.id)"
> >
@let isSelected = identity.id === sessionData?.selectedIdentityId; <img
class="avatar"
<span [src]="getAvatarUrl(identity)"
class="no-select" alt=""
style="overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap" (error)="$any($event.target).src = 'assets/person-fill.svg'"
[class.not-active]="!isSelected" />
> <span class="name">{{ getDisplayName(identity) }}</span>
{{ identity.nick }}
</span>
<div class="sam-flex-grow"></div>
@if(isSelected) {
<lib-icon-button <lib-icon-button
icon="star-fill" icon="gear"
title="Edit identity" title="Identity settings"
style="pointer-events: none; color: var(--bs-pink)" (click)="onClickEditIdentity(identity.id, $event)"
></lib-icon-button>
}
<div class="buttons sam-flex-row gap-h">
@if(!isSelected) {
<lib-icon-button
icon="star-fill"
title="Select identity"
(click)="
onClickSwitchIdentity(identity.id, $event);
toast.show('Identity changed')
"
></lib-icon-button>
}
</div>
<lib-icon-button
icon="arrow-right"
title="Edit identity"
style="pointer-events: none"
></lib-icon-button> ></lib-icon-button>
</div> </div>
} }

View File

@@ -35,34 +35,53 @@
} }
} }
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.identity { .identity {
height: 48px; height: 56px;
min-height: 48px; min-height: 56px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding-left: 16px; gap: 12px;
padding-left: 12px;
padding-right: 8px; padding-right: 8px;
background: var(--background-light); background: var(--background-light);
border-radius: 8px; border-radius: 8px;
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease;
.not-active {
//color: #525b6a;
opacity: 0.4;
}
&:hover { &:hover {
background: var(--background-light-hover); background: var(--background-light-hover);
.buttons {
visibility: visible;
}
} }
.buttons { &.selected {
visibility: hidden; background: rgba(255, 62, 181, 0.15);
border: 1px solid var(--primary);
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--muted);
}
.name {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
} }
} }
} }

View File

@@ -1,8 +1,11 @@
import { Component, inject } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { import {
IconButtonComponent, IconButtonComponent,
Identity_DECRYPTED, Identity_DECRYPTED,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
StorageService, StorageService,
ToastComponent, ToastComponent,
} from '@common'; } from '@common';
@@ -13,21 +16,48 @@ import {
templateUrl: './identities.component.html', templateUrl: './identities.component.html',
styleUrl: './identities.component.scss', styleUrl: './identities.component.scss',
}) })
export class IdentitiesComponent { export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService); readonly storage = inject(StorageService);
readonly #router = inject(Router); readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
}
#loadProfiles() {
const identities = this.storage.getBrowserSessionHandler().browserSessionData?.identities ?? [];
for (const identity of identities) {
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
const profile = this.#profileMetadata.getCachedProfile(pubkey);
this.#profileCache.set(identity.id, profile);
}
}
getAvatarUrl(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id);
return profile?.picture || 'assets/person-fill.svg';
}
getDisplayName(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id) ?? null;
return this.#profileMetadata.getDisplayName(profile) || identity.nick;
}
onClickNewIdentity() { onClickNewIdentity() {
this.#router.navigateByUrl('/new-identity'); this.#router.navigateByUrl('/new-identity');
} }
onClickEditIdentity(identity: Identity_DECRYPTED) { onClickEditIdentity(identityId: string, event: MouseEvent) {
this.#router.navigateByUrl(`/edit-identity/${identity.id}/home`); event.stopPropagation();
this.#router.navigateByUrl(`/edit-identity/${identityId}/home`);
} }
async onClickSwitchIdentity(identityId: string, event: MouseEvent) { async onClickSelectIdentity(identityId: string) {
event.stopPropagation();
await this.storage.switchIdentity(identityId); await this.storage.switchIdentity(identityId);
} }
} }

View File

@@ -11,19 +11,3 @@
git.mleku.dev/mleku/plebeian-signer git.mleku.dev/mleku/plebeian-signer
</a> </a>
<div class="sam-flex-grow"></div>
<div class="sam-card sam-mb" style="align-items: center">
<span>
Made with <i class="bi bi-heart-fill" style="color: red"></i> by
<a href="https://sam-hayes.org" target="_blank">Sam Hayes</a>
</span>
<lib-pubkey
class="sam-mt-h"
value="npub1tgyjshvelwj73t3jy0n3xllgt03elkapfl3k3n0x2wkunegkgrwssfp0u4"
(click)="toast.show('Copied to clipboard')"
></lib-pubkey>
</div>
<lib-toast #toast [bottom]="188"></lib-toast>

View File

@@ -1,10 +1,8 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PubkeyComponent, ToastComponent } from '@common';
import packageJson from '../../../../../../../package.json'; import packageJson from '../../../../../../../package.json';
@Component({ @Component({
selector: 'app-info', selector: 'app-info',
imports: [PubkeyComponent, ToastComponent],
templateUrl: './info.component.html', templateUrl: './info.component.html',
styleUrl: './info.component.scss', styleUrl: './info.component.scss',
}) })

View File

@@ -16,7 +16,6 @@
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"

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { AfterViewInit, Component, ElementRef, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { import {
@@ -16,7 +16,9 @@ import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-se
styleUrl: './vault-login.component.scss', styleUrl: './vault-login.component.scss',
imports: [FormsModule, ConfirmComponent], imports: [FormsModule, ConfirmComponent],
}) })
export class VaultLoginComponent { export class VaultLoginComponent implements AfterViewInit {
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
loginPassword = ''; loginPassword = '';
showInvalidPasswordAlert = false; showInvalidPasswordAlert = false;
@@ -25,6 +27,10 @@ export class VaultLoginComponent {
readonly #startup = inject(StartupService); readonly #startup = inject(StartupService);
readonly #profileMetadata = inject(ProfileMetadataService); readonly #profileMetadata = inject(ProfileMetadataService);
ngAfterViewInit() {
this.passwordInput.nativeElement.focus();
}
toggleType(element: HTMLInputElement) { toggleType(element: HTMLInputElement) {
if (element.type === 'password') { if (element.type === 'password') {
element.type = 'text'; element.type = 'text';