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:
parent
db81f8dfda
commit
a6329d5640
13 changed files with 812 additions and 29 deletions
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 & publish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue