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);
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;

View file

@ -35,6 +35,9 @@ pub struct AppStateView {
pub active_profile: Option<ProfileSummary>,
pub profiles: Vec<ProfileSummary>,
pub settings: Settings,
/// Recently deleted profiles, newest last, for undo.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub undo_history: Vec<ProfileSummary>,
}
impl App {
@ -110,6 +113,7 @@ impl App {
public_key: restored.npub.clone(),
secret_key: "".to_string(),
created_at: restored.created_at,
picture: None,
};
self.vault.profiles.push(stored);
// If no active profile, this restored one becomes active
@ -234,6 +238,7 @@ impl App {
active_profile: profiles::active_summary(&self.vault),
profiles: profiles::summaries(&self.vault),
settings: self.settings.clone(),
undo_history: self.undo_history.clone(),
}
}
}
@ -274,17 +279,26 @@ mod tests {
use crate::errors::ErrorKind;
use crate::profiles;
/// Settings with no relays so tests never touch the network.
fn offline_settings() -> Settings {
Settings {
relays: Vec::new(),
..Default::default()
}
}
fn plaintext_vault() -> Vault {
let mut vault = Vault::empty();
profiles::create_profile(&mut vault, "Alice".to_string(), None).unwrap();
profiles::create_profile(&mut vault, "Bob".to_string(), None).unwrap();
profiles::create_profile(&mut vault, "Alice".to_string(), None, &offline_settings())
.unwrap();
profiles::create_profile(&mut vault, "Bob".to_string(), None, &offline_settings()).unwrap();
vault
}
fn sample_app() -> App {
App {
vault: plaintext_vault(),
settings: Settings::default(),
settings: offline_settings(),
unlock_key: None,
undo_history: Vec::new(),
}
@ -354,7 +368,12 @@ mod tests {
app.lock();
let key = app.vault_key().copied();
let err = profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref())
let err = profiles::create_profile(
&mut app.vault,
"Carol".to_string(),
key.as_ref(),
&app.settings,
)
.expect_err("locked vault must reject new profiles");
assert_eq!(err.kind(), ErrorKind::VaultLocked);
}
@ -366,7 +385,13 @@ mod tests {
.unwrap();
let key = app.vault_key().copied();
profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref()).unwrap();
profiles::create_profile(
&mut app.vault,
"Carol".to_string(),
key.as_ref(),
&app.settings,
)
.unwrap();
let created = app.vault.profiles.last().unwrap();
assert_ne!(

View file

@ -38,6 +38,17 @@ pub enum Request {
SelectProfile {
npub: String,
},
/// Publish a profile's stored label as kind 0 metadata so external
/// clients display its name.
PublishProfileMetadata {
npub: String,
},
/// Store a profile picture URL (or clear it with `None`) and publish it
/// as part of the profile's kind 0 metadata.
SetProfilePicture {
npub: String,
url: Option<String>,
},
PublishNote {
content: String,
},
@ -282,7 +293,8 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Request::CreateProfile { label } => {
let label = normalise_label(&label);
let key = app.vault_key().copied();
let summary = profiles::create_profile(&mut app.vault, label, key.as_ref())?;
let summary =
profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() }))
}
@ -293,6 +305,26 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!(app.state_view()))
}
Request::PublishProfileMetadata { npub } => {
let key = app.vault_key().copied();
let report =
profiles::publish_profile_metadata(&app.vault, &npub, key.as_ref(), &app.settings)?;
Ok(json!(report))
}
Request::SetProfilePicture { npub, url } => {
let key = app.vault_key().copied();
let (summary, report) = profiles::set_profile_picture(
&mut app.vault,
&npub,
url,
key.as_ref(),
&app.settings,
)?;
app.save_vault()?;
Ok(json!({ "profile": summary, "report": report, "state": app.state_view() }))
}
Request::PublishNote { content } => {
let report =
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())

View file

@ -19,6 +19,8 @@ Commands:
list List stored profiles (no secret keys)
switch <npub> Select the active profile
publish <npub> <content> Publish a text note as a specific profile
publish-name <npub> Publish the profile's stored name so other clients show it
set-picture <npub> <url> Set the profile picture (http(s) URL) and publish it
feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50);
--contacts filters to the active profile's contacts
relays list List configured relays
@ -31,6 +33,8 @@ Commands:
settings set theme <light|dark|system>
settings set confirm <true|false>
settings set shorten <true|false>
delete-profile <npub> Delete a profile (moves it to undo stack)
undo-delete Undo the last profile deletion
set-password Encrypt the vault with a password (or change it)
remove-password Remove the vault password (keys back to plaintext)
unlock Verify the vault password for this process
@ -64,6 +68,8 @@ async fn main() -> ExitCode {
"list" => cli_list(),
"switch" => cli_switch(&args),
"publish" => cli_publish(&args).await,
"publish-name" => cli_publish_name(&args),
"set-picture" => cli_set_picture(&args),
"feed" => cli_feed(&args).await,
"relays" => cli_relays(&args).await,
"settings" => cli_settings(&args),
@ -73,6 +79,8 @@ async fn main() -> ExitCode {
"show-secret" => cli_show_secret(&args),
"signer" => cli_signer(&args).await,
"info" => cli_info(),
"delete-profile" => cli_delete_profile(&args),
"undo-delete" => cli_undo_delete(),
"help" | "--help" | "-h" => {
println!("{USAGE}");
return ExitCode::SUCCESS;
@ -107,7 +115,7 @@ fn cli_create(args: &[String]) -> Result<String, AppError> {
.unwrap_or_else(|| "New Profile".to_string());
let mut app = load_app_with_unlock()?;
let key = app.vault_key().copied();
let summary = profiles::create_profile(&mut app.vault, label, key.as_ref())?;
let summary = profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
app.save_vault()?;
Ok(format!(
"Created profile \"{}\": {}",
@ -159,6 +167,56 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
Ok(lines.join("\n"))
}
fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
let [_, _, npub, url] = args else {
return Err(AppError::config(
"Usage: nostr-manager-backend set-picture <npub> <url>",
));
};
let mut app = load_app_with_unlock()?;
let key = app.vault_key().copied();
let (summary, report) = profiles::set_profile_picture(
&mut app.vault,
npub,
Some(url.clone()),
key.as_ref(),
&app.settings,
)?;
app.save_vault()?;
Ok(format!(
"Picture set for \"{}\"; accepted by {} relay(s).",
summary.label,
report.succeeded.len()
))
}
fn cli_publish_name(args: &[String]) -> Result<String, AppError> {
let npub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend publish-name <npub>"))?;
let app = load_app_with_unlock()?;
let key = app.vault_key().copied();
let label = profiles::profile_label(&app.vault, npub)
.ok_or_else(|| AppError::profile_not_found(npub))?
.to_string();
let report = profiles::publish_profile_metadata(&app.vault, npub, key.as_ref(), &app.settings)?;
let mut lines = vec![
format!("Publishing name \"{}\" for {npub}", label),
format!("Accepted by {} relay(s).", report.succeeded.len()),
];
if let Some(failed) = report.failed.first() {
lines.push(format!(
"{} relay(s) did not accept it; retry later or check relay status.",
report.failed.len()
));
let _ = failed;
}
Ok(lines.join("\n"))
}
async fn cli_feed(args: &[String]) -> Result<String, AppError> {
let mut contacts = false;
let mut rest = &args[2..];
@ -495,6 +553,7 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary
npub: stored.public_key,
created_at: stored.created_at,
is_active: false,
picture: stored.picture,
})
}
@ -527,6 +586,7 @@ fn cli_undo_delete() -> Result<String, AppError> {
public_key: restored.npub.clone(),
secret_key: "".to_string(),
created_at: restored.created_at,
picture: None,
};
app.vault.profiles.push(stored);
if app.vault.active_profile.is_none() {

View file

@ -1,11 +1,20 @@
use nostr_sdk::prelude::*;
use serde::Serialize;
use std::time::Duration;
use zeroize::Zeroizing;
use crate::crypto::VaultKey;
use crate::errors::AppError;
use crate::publish::RelayFailure;
use crate::relays;
use crate::settings::Settings;
use crate::vault::{unix_timestamp, StoredProfile, Vault};
/// How long to wait for relays to accept a connection attempt.
const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// How long to wait for a single relay to accept the metadata event.
const METADATA_SEND_TIMEOUT: Duration = Duration::from_secs(15);
/// A safe view of a profile that contains no secret key material.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ProfileSummary {
@ -15,6 +24,8 @@ pub struct ProfileSummary {
/// Unix timestamp of creation.
pub created_at: u64,
pub is_active: bool,
/// Public URL of the profile picture, when one has been set.
pub picture: Option<String>,
}
/// A secret key revealed after the vault is unlocked, in both the raw hex and
@ -37,6 +48,7 @@ pub fn create_profile(
vault: &mut Vault,
label: String,
key: Option<&VaultKey>,
settings: &Settings,
) -> Result<ProfileSummary, AppError> {
if vault.is_encrypted() && key.is_none() {
return Err(AppError::vault_locked());
@ -63,6 +75,7 @@ pub fn create_profile(
public_key: public_key.clone(),
secret_key: stored_secret,
created_at,
picture: None,
};
let is_active = vault.active_profile.is_none();
@ -72,14 +85,243 @@ pub fn create_profile(
vault.profiles.push(profile);
// Publish kind 0 metadata event so other clients can see the username/display name.
// Best-effort: relay failures here never block profile creation.
let relay_urls = relays::enabled_urls(settings);
if !relay_urls.is_empty() {
publish_metadata_blocking(&keys, &label, None, relay_urls);
}
Ok(ProfileSummary {
label,
npub: public_key,
created_at,
is_active,
picture: None,
})
}
/// The per-relay outcome of publishing a profile metadata event.
#[derive(Debug, Clone, Serialize)]
pub struct MetadataPublishReport {
/// Relays that accepted the event.
pub succeeded: Vec<String>,
/// Relays that rejected or timed out.
pub failed: Vec<RelayFailure>,
}
/// Publish a profile's stored label (and picture, when set) as kind 0 metadata
/// so external clients (Iris, Yakihonne, ...) display its name. Returns a
/// per-relay report.
///
/// `key` must be the unlocked vault key when the vault is password-protected.
pub fn publish_profile_metadata(
vault: &Vault,
npub: &str,
key: Option<&VaultKey>,
settings: &Settings,
) -> Result<MetadataPublishReport, AppError> {
let stored = find_profile(vault, npub)?;
let secret_hex = resolve_secret_key(vault, npub, key)?;
let secret_key = parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key);
let relay_urls = relays::enabled_urls(settings);
if relay_urls.is_empty() {
return Err(AppError::no_enabled_relays());
}
Ok(publish_metadata_blocking(
&keys,
&stored.label,
stored.picture.clone(),
relay_urls,
))
}
/// Store a profile picture URL and immediately publish it as part of the
/// profile's kind 0 metadata.
///
/// Pass `None` to clear the picture. Returns the updated summary plus the
/// per-relay publish report.
pub fn set_profile_picture(
vault: &mut Vault,
npub: &str,
url: Option<String>,
key: Option<&VaultKey>,
settings: &Settings,
) -> Result<(ProfileSummary, MetadataPublishReport), AppError> {
if let Some(url) = &url {
validate_picture_url(url)?;
}
// Resolve and sign before mutating so a locked vault or bad key changes
// nothing on disk.
let secret_hex = resolve_secret_key(vault, npub, key)?;
let secret_key = parse_secret_key(&secret_hex)?;
let stored = find_profile_mut(vault, npub)?;
stored.picture = url;
let (label, npub, created_at, public_key, picture) = (
stored.label.clone(),
stored.public_key.clone(),
stored.created_at,
stored.public_key.clone(),
stored.picture.clone(),
);
drop(stored);
let summary = ProfileSummary {
label,
npub,
created_at,
is_active: vault.active_profile.as_deref() == Some(public_key.as_str()),
picture,
};
let relay_urls = relays::enabled_urls(settings);
if relay_urls.is_empty() {
// The vault change stands; publishing can be retried later via the
// explicit "publish name" action once a relay is enabled.
return Ok((
summary,
MetadataPublishReport {
succeeded: Vec::new(),
failed: Vec::new(),
},
));
}
let keys = Keys::new(secret_key);
let report =
publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls);
Ok((summary, report))
}
/// Validate that a picture URL is a well-formed http(s) URL.
fn validate_picture_url(url: &str) -> Result<(), AppError> {
let parsed = Url::parse(url)
.map_err(|e| AppError::config(format!("The picture URL is not valid: {e}")))?;
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err(AppError::config(
"The picture URL must start with http:// or https://",
));
}
Ok(())
}
fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> {
vault
.profiles
.iter()
.find(|p| p.public_key == npub)
.ok_or_else(|| AppError::profile_not_found(npub))
}
fn find_profile_mut<'a>(
vault: &'a mut Vault,
npub: &str,
) -> Result<&'a mut StoredProfile, AppError> {
vault
.profiles
.iter_mut()
.find(|p| p.public_key == npub)
.ok_or_else(|| AppError::profile_not_found(npub))
}
/// Run the async publish on a dedicated thread with its own tokio runtime.
///
/// This keeps the call synchronous for callers while staying safe when invoked
/// from inside an existing runtime (e.g. the IPC server): `block_on` panics
/// when nested, but here it always runs on a fresh OS thread instead.
fn publish_metadata_blocking(
keys: &Keys,
label: &str,
picture: Option<String>,
relay_urls: Vec<String>,
) -> MetadataPublishReport {
let keys = keys.clone();
let label = label.to_string();
std::thread::spawn(move || {
tokio::runtime::Runtime::new()
.expect("metadata runtime")
.block_on(publish_metadata_async(&keys, &label, picture, relay_urls))
})
.join()
.expect("metadata publish thread panicked")
}
async fn publish_metadata_async(
keys: &Keys,
label: &str,
picture: Option<String>,
relay_urls: Vec<String>,
) -> MetadataPublishReport {
let mut metadata = Metadata::new().name(label).display_name(label);
if let Some(picture) = &picture {
if let Ok(parsed) = Url::parse(picture) {
metadata = metadata.picture(parsed);
}
}
let event = match EventBuilder::new(Kind::Metadata, metadata.as_json())
.sign(keys)
.await
{
Ok(event) => event,
Err(e) => {
return MetadataPublishReport {
succeeded: Vec::new(),
failed: relay_urls
.into_iter()
.map(|url| RelayFailure {
url,
error: "Could not sign the profile metadata.".to_string(),
details: Some(format!("{e}")),
})
.collect(),
};
}
};
let client = Client::new(keys.clone());
for url in &relay_urls {
let _ = client.add_relay(url.as_str()).await;
}
client.connect().await;
let _ = client.wait_for_connection(METADATA_CONNECT_TIMEOUT).await;
let mut succeeded = Vec::new();
let mut failed = Vec::new();
for url in &relay_urls {
match client.relay(url.as_str()).await {
Ok(relay) => {
match tokio::time::timeout(METADATA_SEND_TIMEOUT, relay.send_event(&event)).await {
Ok(Ok(_)) => succeeded.push(url.clone()),
Ok(Err(err)) => failed.push(failure_for(url, &err)),
Err(_) => failed.push(RelayFailure {
url: url.clone(),
error: "The relay did not respond in time.".to_string(),
details: Some(
"Timed out while waiting for the relay to accept the metadata."
.to_string(),
),
}),
}
}
Err(err) => failed.push(failure_for(url, &err)),
}
}
client.disconnect().await;
MetadataPublishReport { succeeded, failed }
}
fn failure_for(url: &str, err: &impl std::fmt::Display) -> RelayFailure {
let (error, details) = crate::publish::relay_error_message(err);
RelayFailure {
url: url.to_string(),
error,
details: Some(details),
}
}
/// Safe summaries of every stored profile, newest last. Never includes
/// secret keys.
pub fn summaries(vault: &Vault) -> Vec<ProfileSummary> {
@ -106,6 +348,7 @@ fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary {
npub: profile.public_key.clone(),
created_at: profile.created_at,
is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()),
picture: profile.picture.clone(),
}
}
@ -220,21 +463,31 @@ mod tests {
public_key: "npub1alice".to_string(),
secret_key: "00".repeat(32),
created_at: 1,
picture: None,
});
vault.profiles.push(StoredProfile {
label: "Bob".to_string(),
public_key: "npub1bob".to_string(),
secret_key: "11".repeat(32),
created_at: 2,
picture: None,
});
vault
}
/// Settings with no relays so tests never touch the network.
fn offline_settings() -> Settings {
Settings {
relays: Vec::new(),
..Default::default()
}
}
#[test]
fn create_profile_generates_valid_keys() {
let mut vault = Vault::empty();
let summary =
create_profile(&mut vault, "Newbie".to_string(), None).expect("should create");
let summary = create_profile(&mut vault, "Newbie".to_string(), None, &offline_settings())
.expect("should create");
assert!(summary.npub.starts_with("npub1"));
assert_eq!(summary.label, "Newbie");
assert_eq!(vault.profiles.len(), 1);
@ -252,7 +505,8 @@ mod tests {
fn create_profile_keeps_existing_active() {
let mut vault = populated_vault();
vault.active_profile = Some("npub1alice".to_string());
let summary = create_profile(&mut vault, "Carol".to_string(), None).unwrap();
let summary =
create_profile(&mut vault, "Carol".to_string(), None, &offline_settings()).unwrap();
assert!(!summary.is_active);
assert_eq!(vault.active_profile.as_deref(), Some("npub1alice"));
}
@ -325,7 +579,8 @@ mod tests {
#[test]
fn reveal_returns_hex_and_nsec_for_plaintext_vault() {
let mut vault = Vault::empty();
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
let summary =
create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap();
let revealed = reveal_secret_key(&vault, &summary.npub, None).expect("must reveal");
assert_eq!(revealed.hex.len(), 64, "hex secret is 32 bytes");
@ -342,7 +597,8 @@ mod tests {
#[test]
fn reveal_decrypts_an_encrypted_profile_when_unlocked() {
let mut vault = Vault::empty();
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
let summary =
create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap();
let plaintext = vault.profiles[0].secret_key.clone();
let salt = crate::crypto::generate_salt().unwrap();
@ -374,7 +630,8 @@ mod tests {
#[test]
fn reveal_rejects_a_locked_vault() {
let mut vault = Vault::empty();
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
let summary =
create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap();
vault.crypto = Some(VaultCrypto {
kdf: KdfParams {
algorithm: "argon2id".to_string(),
@ -404,6 +661,101 @@ mod tests {
assert_eq!(profile_label(&vault, "npub1ghost"), None);
}
#[test]
fn publish_metadata_missing_profile_errors() {
let vault = Vault::empty();
let err = publish_profile_metadata(&vault, "npub1ghost", None, &offline_settings())
.expect_err("missing profile must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
}
#[test]
fn publish_metadata_no_relays_errors_before_network_work() {
let mut vault = Vault::empty();
let summary =
create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap();
// Offline settings have no relays; the call must fail fast with a
// clear error rather than attempting any network work.
let err = publish_profile_metadata(&vault, &summary.npub, None, &offline_settings())
.expect_err("no relays must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::NoEnabledRelays);
}
#[test]
fn set_picture_stores_url_and_skips_publish_without_relays() {
let mut vault = Vault::empty();
let summary =
create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap();
let url = "https://cdn.example.com/alice.png".to_string();
let (updated, report) = set_profile_picture(
&mut vault,
&summary.npub,
Some(url.clone()),
None,
&offline_settings(),
)
.expect("setting a picture must work offline");
assert_eq!(updated.picture.as_deref(), Some(url.as_str()));
assert_eq!(
vault.profiles[0].picture.as_deref(),
Some(url.as_str()),
"vault must remember the picture URL"
);
// No relays enabled: nothing published, but the change still stands.
assert!(report.succeeded.is_empty());
assert!(report.failed.is_empty());
// Clearing the picture also persists.
let (cleared, _) =
set_profile_picture(&mut vault, &summary.npub, None, None, &offline_settings())
.unwrap();
assert!(cleared.picture.is_none());
assert!(vault.profiles[0].picture.is_none());
}
#[test]
fn set_picture_rejects_non_http_urls() {
let mut vault = Vault::empty();
let summary =
create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap();
for bad in [
"not a url",
"ftp://cdn.example.com/a.png",
"javascript:alert(1)",
] {
let err = set_profile_picture(
&mut vault,
&summary.npub,
Some(bad.to_string()),
None,
&offline_settings(),
)
.expect_err("invalid picture URL must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::Config);
}
assert!(
vault.profiles[0].picture.is_none(),
"nothing stored on failure"
);
}
#[test]
fn set_picture_missing_profile_errors() {
let mut vault = Vault::empty();
let err = set_profile_picture(
&mut vault,
"npub1ghost",
Some("https://example.com/x.png".to_string()),
None,
&offline_settings(),
)
.expect_err("missing profile must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
}
/// Delete a profile by npub, returning the deleted profile for undo.
/// The vault must not be encrypted, or the key must be provided.
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
@ -422,6 +774,7 @@ mod tests {
npub: stored.public_key,
created_at: stored.created_at,
is_active: false,
picture: stored.picture,
})
}
}

View file

@ -241,7 +241,7 @@ async fn publish_with_keys(
/// The underlying error types come from transitive dependencies, so the
/// classification is based on the rendered message rather than brittle enum
/// matching across versions.
fn relay_error_message(err: &impl std::fmt::Display) -> (String, String) {
pub(crate) fn relay_error_message(err: &impl std::fmt::Display) -> (String, String) {
let technical = err.to_string();
let lower = technical.to_lowercase();
let concise = if lower.contains("timed out") || lower.contains("timeout") {
@ -334,7 +334,13 @@ mod tests {
#[test]
fn publish_with_no_enabled_relays_errors() {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
crate::profiles::create_profile(
&mut vault,
"A".to_string(),
None,
&settings_with_no_relays(),
)
.unwrap();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
@ -346,7 +352,13 @@ mod tests {
#[test]
fn publish_with_invalid_stored_key_errors() {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
crate::profiles::create_profile(
&mut vault,
"A".to_string(),
None,
&settings_with_no_relays(),
)
.unwrap();
vault.profiles[0].secret_key = "zz-not-hex".to_string();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
@ -359,7 +371,13 @@ mod tests {
#[test]
fn publish_locked_encrypted_vault_errors() {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
crate::profiles::create_profile(
&mut vault,
"A".to_string(),
None,
&settings_with_no_relays(),
)
.unwrap();
vault.crypto = Some(crate::vault::VaultCrypto {
kdf: crate::vault::KdfParams {
algorithm: "argon2id".to_string(),

View file

@ -47,11 +47,21 @@ pub async fn nip98_authorization(
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::Settings;
use crate::vault::Vault;
/// Settings with no relays so tests never touch the network.
fn offline_settings() -> Settings {
Settings {
relays: Vec::new(),
..Default::default()
}
}
fn vault_with_profile() -> Vault {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
crate::profiles::create_profile(&mut vault, "A".to_string(), None, &offline_settings())
.unwrap();
vault
}

View file

@ -35,6 +35,10 @@ pub struct StoredProfile {
pub secret_key: String,
/// Unix timestamp of creation.
pub created_at: u64,
/// Public URL of the profile picture, when one has been set. Absent for
/// profiles stored before pictures were introduced.
#[serde(default)]
pub picture: Option<String>,
}
/// KDF parameters that encrypted a vault. Stored so future key-derivation
@ -412,6 +416,7 @@ mod tests {
public_key: "npub1test".to_string(),
secret_key: "00ff".to_string(),
created_at: 1_700_000_000,
picture: None,
}
}