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
```
**Important:** After making any code changes, rebuild both extensions before testing:
```bash
npm run build:chrome && npm run build:firefox
```
## Architecture
### Monorepo Structure

View File

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

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
"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",
"options_page": "options.html",
"permissions": [

View File

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

View File

@@ -35,34 +35,53 @@
}
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.identity {
height: 48px;
min-height: 48px;
height: 56px;
min-height: 56px;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 16px;
gap: 12px;
padding-left: 12px;
padding-right: 8px;
background: var(--background-light);
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
.not-active {
//color: #525b6a;
opacity: 0.4;
}
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
.buttons {
visibility: visible;
}
}
.buttons {
visibility: hidden;
&.selected {
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 {
IconButtonComponent,
Identity_DECRYPTED,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
StorageService,
ToastComponent,
} from '@common';
@@ -13,21 +16,48 @@ import {
styleUrl: './identities.component.scss',
imports: [IconButtonComponent, ToastComponent],
})
export class IdentitiesComponent {
export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService);
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() {
this.#router.navigateByUrl('/new-identity');
}
onClickEditIdentity(identity: Identity_DECRYPTED) {
this.#router.navigateByUrl(`/edit-identity/${identity.id}/home`);
onClickEditIdentity(identityId: string, event: MouseEvent) {
event.stopPropagation();
this.#router.navigateByUrl(`/edit-identity/${identityId}/home`);
}
async onClickSwitchIdentity(identityId: string, event: MouseEvent) {
event.stopPropagation();
async onClickSelectIdentity(identityId: string) {
await this.storage.switchIdentity(identityId);
}
}

View File

@@ -14,19 +14,3 @@
git.mleku.dev/mleku/plebeian-signer
</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 { PubkeyComponent, ToastComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
selector: 'app-info',
imports: [PubkeyComponent, ToastComponent],
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})

View File

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

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
"version": "0.0.6",
"version": "0.0.7",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

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

View File

@@ -35,34 +35,53 @@
}
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.identity {
height: 48px;
min-height: 48px;
height: 56px;
min-height: 56px;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 16px;
gap: 12px;
padding-left: 12px;
padding-right: 8px;
background: var(--background-light);
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
.not-active {
//color: #525b6a;
opacity: 0.4;
}
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
.buttons {
visibility: visible;
}
}
.buttons {
visibility: hidden;
&.selected {
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 {
IconButtonComponent,
Identity_DECRYPTED,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
StorageService,
ToastComponent,
} from '@common';
@@ -13,21 +16,48 @@ import {
templateUrl: './identities.component.html',
styleUrl: './identities.component.scss',
})
export class IdentitiesComponent {
export class IdentitiesComponent implements OnInit {
readonly storage = inject(StorageService);
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() {
this.#router.navigateByUrl('/new-identity');
}
onClickEditIdentity(identity: Identity_DECRYPTED) {
this.#router.navigateByUrl(`/edit-identity/${identity.id}/home`);
onClickEditIdentity(identityId: string, event: MouseEvent) {
event.stopPropagation();
this.#router.navigateByUrl(`/edit-identity/${identityId}/home`);
}
async onClickSwitchIdentity(identityId: string, event: MouseEvent) {
event.stopPropagation();
async onClickSelectIdentity(identityId: string) {
await this.storage.switchIdentity(identityId);
}
}

View File

@@ -11,19 +11,3 @@
git.mleku.dev/mleku/plebeian-signer
</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 { PubkeyComponent, ToastComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
selector: 'app-info',
imports: [PubkeyComponent, ToastComponent],
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})

View File

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