Publish profile metadata (name + picture) so external clients show it

Profiles created in the app never published a kind 0 metadata event, so
clients like Iris and Yakihonne showed generated petnames ("evil iguana")
or a truncated npub instead of the user's chosen name.

- Publish kind 0 metadata (name/display_name) automatically on creation
- Add "Publish name" action (GUI button + publish-name CLI) for existing
  profiles, with a per-relay success/failure report
- Add profile pictures: optional picture URL persisted in the vault,
  set via GUI modal (URL paste or nostr.build upload), set-picture CLI,
  and included in the published metadata; avatars render it in-app
- Run metadata publishing on its own thread so sync and async callers
  never nest tokio runtimes
- Expose undo_history in the state view and fix typecheck errors left by
  the unfinished delete/undo work (variants, icon, null-safety)
- Use offline relay settings in tests: Settings::default() points at real
  relays, which tests were silently publishing to (suite: 126s -> ~3s)

Verification: cargo test 96 passed; clippy/fmt/build clean. Frontend:
78 tests, typecheck, lint, format, vite and electron builds all pass.
This commit is contained in:
Avi 2026-08-22 18:42:20 -05:00
commit a6329d5640
13 changed files with 812 additions and 29 deletions

View file

@ -4,10 +4,19 @@ interface AvatarProps {
npub: string;
label: string;
size?: 'md' | 'lg';
/** Optional profile picture URL; shown instead of initials when present. */
picture?: string | null;
}
export function Avatar({ npub, label, size = 'md' }: AvatarProps) {
export function Avatar({ npub, label, size = 'md', picture }: AvatarProps) {
const colors = avatarColorFor(npub);
if (picture) {
return (
<span className={`avatar avatar-${size} avatar-picture`} aria-hidden="true">
<img src={picture} alt="" loading="lazy" />
</span>
);
}
return (
<span
className={`avatar avatar-${size}`}

View file

@ -3,6 +3,7 @@ import type {
BackendResponse,
FeedItem,
LinkPreview,
MetadataPublishReport,
PickedImage,
ProfileSummary,
PublishReport,
@ -47,9 +48,16 @@ async function call<T>(method: string, params: Record<string, unknown> = {}): Pr
export const api = {
init: () => call<AppState>('init'),
getState: () => call<AppState>('get_state'),
createProfile: (label: string) =>
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label }),
createProfile: (label: string, settings?: Settings) =>
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label, settings }),
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
publishProfileMetadata: (npub: string) =>
call<MetadataPublishReport>('publish_profile_metadata', { npub }),
setProfilePicture: (npub: string, url: string | null) =>
call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>(
'set_profile_picture',
{ npub, url },
),
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
feedGet: (limit?: number, contactsOnly = false) =>
call<FeedItem[]>('feed_get', {
@ -82,5 +90,9 @@ export const api = {
signerStatus: () => call<SignerStatus>('signer_status'),
signerApprove: (id: string, approved: boolean) =>
call<SignerStatus>('signer_approve', { id, approved }),
deleteProfile: (npub: string) => call<ProfileSummary>('delete_profile', { npub }),
undoDelete: () => call<ProfileSummary>('undo_delete'),
copyText: (text: string) => window.backend.copyText(text),
};

View file

@ -34,6 +34,8 @@ export interface ProfileSummary {
/** Unix timestamp of creation. */
created_at: number;
is_active: boolean;
/** Public URL of the profile picture, when one has been set. */
picture?: string | null;
}
export interface RelayConfig {
@ -63,6 +65,12 @@ export interface PublishReport {
failed: RelayFailure[];
}
/** Per-relay outcome of publishing a profile's name as kind 0 metadata. */
export interface MetadataPublishReport {
succeeded: string[];
failed: RelayFailure[];
}
/** A single note shown in the aggregated feed. */
export interface FeedItem {
/** Bech32 note id. */
@ -95,6 +103,8 @@ export interface AppState {
active_profile: ProfileSummary | null;
profiles: ProfileSummary[];
settings: Settings;
/** Recently deleted profiles, newest last, for undo. */
undo_history?: ProfileSummary[];
}
/** A secret key revealed after the vault is unlocked. */

View file

@ -6,6 +6,7 @@ import { CopyButton } from '../components/CopyButton';
import { EmptyState } from '../components/EmptyState';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { Modal } from '../components/Modal';
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
import { formatDate, shortenNpub } from '../lib/format';
import { useApp } from '../state/AppProvider';
@ -15,14 +16,37 @@ interface ProfilesScreenProps {
}
export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
const { state, selectProfile } = useApp();
const { state, selectProfile, deleteProfile, undoDelete, publishProfileMetadata } = useApp();
const [selecting, setSelecting] = useState<string | null>(null);
const [publishing, setPublishing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
const [pictureTarget, setPictureTarget] = useState<PictureTarget | null>(null);
const profiles = state?.profiles ?? [];
const shorten = state?.settings.shorten_npub ?? true;
const undoHistory = state?.undo_history ?? [];
const lastDeleted = undoHistory[undoHistory.length - 1] ?? null;
const onPublishName = async (npub: string, label: string) => {
setError(null);
setNotice(null);
setPublishing(npub);
try {
const report = await publishProfileMetadata(npub);
setNotice(
report.failed.length === 0
? `Published "${label}" to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.`
: `Published "${label}" to ${report.succeeded.length} relay(s); ${report.failed.length} did not accept it.`,
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPublishing(null);
}
};
if (profiles.length === 0) {
return (
@ -31,6 +55,26 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
<header className="page-head">
<h1>Profiles</h1>
</header>
{lastDeleted && (
<div
className="undo-bar"
style={{
margin: '12px 0',
padding: '8px 12px',
background: 'var(--token-item-bg, #f0f0f0)',
borderRadius: '4px',
}}
>
<Button
variant="secondary"
style={{ marginRight: '8px' }}
size="sm"
onClick={() => void undoDelete()}
>
<Icon name="refresh" size={14} /> Restore {lastDeleted.label}
</Button>
</div>
)}
<EmptyState
icon={<Icon name="users" size={30} />}
title="No profiles yet"
@ -77,6 +121,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
</header>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
{notice && (
<p className="muted" role="status">
{notice}
</p>
)}
<div className="profile-grid">
{profiles.map((profile) => (
@ -85,7 +134,12 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
className={`profile-card${profile.is_active ? ' is-active' : ''}`}
>
<div className="profile-card-top">
<Avatar npub={profile.npub} label={profile.label} size="lg" />
<Avatar
npub={profile.npub}
label={profile.label}
size="lg"
picture={profile.picture}
/>
<div className="profile-card-meta">
<h3>{profile.label}</h3>
<code className="mono" title={profile.npub}>
@ -99,6 +153,39 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
<div className="profile-card-actions">
<CopyButton text={profile.npub} label="public key" />
<Button
variant="secondary"
size="sm"
loading={publishing === profile.npub}
onClick={() => void onPublishName(profile.npub, profile.label)}
>
<Icon name="publish" size={14} /> Publish name
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
setPictureTarget({
npub: profile.npub,
label: profile.label,
url: profile.picture ?? '',
})
}
>
<Icon name="edit" size={14} /> Picture
</Button>
{/* Delete button - appears for all profiles */}
<Button
variant="danger"
size="sm"
onClick={() => {
if (window.confirm(`Delete profile "${profile.label}"?`)) {
void deleteProfile(profile.npub);
}
}}
>
<Icon name="trash" size={16} /> Delete
</Button>
<Button
variant="ghost"
size="sm"
@ -131,7 +218,126 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
profile={revealTarget}
onClose={() => setRevealTarget(null)}
/>
{pictureTarget && (
<PictureModal
target={pictureTarget}
onClose={() => setPictureTarget(null)}
onSaved={(message) => {
setNotice(message);
setPictureTarget(null);
}}
onError={setError}
onSavingChange={setPublishing}
/>
)}
</div>
</div>
);
}
interface PictureTarget {
npub: string;
label: string;
url: string;
}
function PictureModal({
target,
onClose,
onSaved,
onError,
onSavingChange,
}: {
target: PictureTarget;
onClose: () => void;
onSaved: (message: string) => void;
onError: (message: string | null) => void;
onSavingChange: (npub: string | null) => void;
}) {
const { pickImages, uploadImage, setProfilePicture } = useApp();
const [url, setUrl] = useState(target.url);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const save = async (nextUrl: string | null) => {
onError(null);
setSaving(true);
onSavingChange(target.npub);
try {
const report = await setProfilePicture(target.npub, nextUrl);
onSaved(
report.failed.length === 0
? `Picture for "${target.label}" published to ${report.succeeded.length} relay(s).`
: `Picture saved for "${target.label}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
onSavingChange(null);
}
};
const onUpload = async () => {
onError(null);
setUploading(true);
try {
const picked = await pickImages();
if (picked.length === 0) {
return;
}
const uploaded = await uploadImage(picked[0].token);
setUrl(uploaded.url);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setUploading(false);
}
};
const canSave = /^https?:\/\//.test(url.trim());
return (
<Modal open title={`Profile picture — ${target.label}`} onClose={onClose}>
<div className="picture-modal">
<Avatar npub={target.npub} label={target.label} size="lg" picture={canSave ? url : null} />
<p className="muted">
The picture is stored as a public URL inside your Nostr profile and shown by every client.
</p>
<div className="field">
<label htmlFor="picture-url">Picture URL</label>
<input
id="picture-url"
type="text"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://…/avatar.png"
autoComplete="off"
/>
</div>
<Button variant="ghost" onClick={() => void onUpload()} loading={uploading}>
<Icon name="plus" size={14} /> Upload an image instead
</Button>
<div className="modal-actions">
{target.url && (
<Button variant="danger" onClick={() => void save(null)} disabled={saving || uploading}>
Remove picture
</Button>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void save(url.trim())}
loading={saving}
disabled={!canSave}
>
Save &amp; publish
</Button>
</div>
</div>
</Modal>
);
}

View file

@ -12,6 +12,7 @@ import type {
AppState,
FeedItem,
LinkPreview,
MetadataPublishReport,
PickedImage,
ProfileSummary,
PublishReport,
@ -38,6 +39,8 @@ interface AppContextValue {
refresh: () => Promise<void>;
createProfile: (label: string) => Promise<ProfileSummary>;
selectProfile: (npub: string) => Promise<void>;
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
publishNote: (content: string) => Promise<PublishReport>;
recordPublishFailure: (message: string, details?: string | null) => void;
clearLastPublish: () => void;
@ -62,6 +65,8 @@ interface AppContextValue {
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
deleteProfile: (npub: string) => Promise<ProfileSummary>;
undoDelete: () => Promise<ProfileSummary>;
copyText: (text: string) => Promise<void>;
}
@ -101,17 +106,34 @@ export function AppProvider({ children }: { children: ReactNode }) {
};
}, []);
const createProfile = useCallback(async (label: string): Promise<ProfileSummary> => {
const result = await api.createProfile(label);
setState(result.state);
return result.profile;
}, []);
const createProfile = useCallback(
async (label: string): Promise<ProfileSummary> => {
const result = await api.createProfile(label, state?.settings);
setState(result.state);
return result.profile;
},
[state?.settings],
);
const selectProfile = useCallback(async (npub: string) => {
const fresh = await api.selectProfile(npub);
setState(fresh);
}, []);
const publishProfileMetadata = useCallback(
(npub: string) => api.publishProfileMetadata(npub),
[],
);
const setProfilePicture = useCallback(
async (npub: string, url: string | null): Promise<MetadataPublishReport> => {
const result = await api.setProfilePicture(npub, url);
setState(result.state);
return result.report;
},
[],
);
const publishNote = useCallback(async (content: string): Promise<PublishReport> => {
const report = await api.publishNote(content);
setLastPublish({ report, error: null, details: null, at: Date.now() });
@ -186,6 +208,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
return api.signerApprove(id, approved);
}, []);
const deleteProfile = useCallback((npub: string) => api.deleteProfile(npub), []);
const undoDelete = useCallback(() => api.undoDelete(), []);
const copyText = useCallback((text: string) => api.copyText(text), []);
useThemeSync(state?.settings.theme);
@ -221,6 +246,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerDisconnect,
signerStatus,
signerApprove,
deleteProfile,
undoDelete,
publishProfileMetadata,
setProfilePicture,
copyText,
}),
[
@ -231,7 +260,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
refresh,
createProfile,
selectProfile,
publishProfileMetadata,
setProfilePicture,
publishNote,
deleteProfile,
undoDelete,
recordPublishFailure,
clearLastPublish,
feedGet,

View file

@ -916,6 +916,16 @@ select {
font-size: 21px;
}
.avatar-picture {
overflow: hidden;
}
.avatar-picture img {
width: 100%;
height: 100%;
object-fit: cover;
}
.relay-status-list {
list-style: none;
margin: 0;