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;
|
npub: string;
|
||||||
label: string;
|
label: string;
|
||||||
size?: 'md' | 'lg';
|
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);
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`avatar avatar-${size}`}
|
className={`avatar avatar-${size}`}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
BackendResponse,
|
BackendResponse,
|
||||||
FeedItem,
|
FeedItem,
|
||||||
LinkPreview,
|
LinkPreview,
|
||||||
|
MetadataPublishReport,
|
||||||
PickedImage,
|
PickedImage,
|
||||||
ProfileSummary,
|
ProfileSummary,
|
||||||
PublishReport,
|
PublishReport,
|
||||||
|
|
@ -47,9 +48,16 @@ async function call<T>(method: string, params: Record<string, unknown> = {}): Pr
|
||||||
export const api = {
|
export const api = {
|
||||||
init: () => call<AppState>('init'),
|
init: () => call<AppState>('init'),
|
||||||
getState: () => call<AppState>('get_state'),
|
getState: () => call<AppState>('get_state'),
|
||||||
createProfile: (label: string) =>
|
createProfile: (label: string, settings?: Settings) =>
|
||||||
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label }),
|
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label, settings }),
|
||||||
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
|
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 }),
|
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
|
||||||
feedGet: (limit?: number, contactsOnly = false) =>
|
feedGet: (limit?: number, contactsOnly = false) =>
|
||||||
call<FeedItem[]>('feed_get', {
|
call<FeedItem[]>('feed_get', {
|
||||||
|
|
@ -82,5 +90,9 @@ export const api = {
|
||||||
signerStatus: () => call<SignerStatus>('signer_status'),
|
signerStatus: () => call<SignerStatus>('signer_status'),
|
||||||
signerApprove: (id: string, approved: boolean) =>
|
signerApprove: (id: string, approved: boolean) =>
|
||||||
call<SignerStatus>('signer_approve', { id, approved }),
|
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),
|
copyText: (text: string) => window.backend.copyText(text),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ export interface ProfileSummary {
|
||||||
/** Unix timestamp of creation. */
|
/** Unix timestamp of creation. */
|
||||||
created_at: number;
|
created_at: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
/** Public URL of the profile picture, when one has been set. */
|
||||||
|
picture?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RelayConfig {
|
export interface RelayConfig {
|
||||||
|
|
@ -63,6 +65,12 @@ export interface PublishReport {
|
||||||
failed: RelayFailure[];
|
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. */
|
/** A single note shown in the aggregated feed. */
|
||||||
export interface FeedItem {
|
export interface FeedItem {
|
||||||
/** Bech32 note id. */
|
/** Bech32 note id. */
|
||||||
|
|
@ -95,6 +103,8 @@ export interface AppState {
|
||||||
active_profile: ProfileSummary | null;
|
active_profile: ProfileSummary | null;
|
||||||
profiles: ProfileSummary[];
|
profiles: ProfileSummary[];
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
/** Recently deleted profiles, newest last, for undo. */
|
||||||
|
undo_history?: ProfileSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A secret key revealed after the vault is unlocked. */
|
/** A secret key revealed after the vault is unlocked. */
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { CopyButton } from '../components/CopyButton';
|
||||||
import { EmptyState } from '../components/EmptyState';
|
import { EmptyState } from '../components/EmptyState';
|
||||||
import { ErrorText } from '../components/ErrorText';
|
import { ErrorText } from '../components/ErrorText';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
|
import { Modal } from '../components/Modal';
|
||||||
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
||||||
import { formatDate, shortenNpub } from '../lib/format';
|
import { formatDate, shortenNpub } from '../lib/format';
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
@ -15,14 +16,37 @@ interface ProfilesScreenProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfilesScreen({ onCreateProfile }: 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 [selecting, setSelecting] = useState<string | null>(null);
|
||||||
|
const [publishing, setPublishing] = useState<string | null>(null);
|
||||||
const [error, setError] = 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 [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
|
||||||
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
||||||
|
const [pictureTarget, setPictureTarget] = useState<PictureTarget | null>(null);
|
||||||
|
|
||||||
const profiles = state?.profiles ?? [];
|
const profiles = state?.profiles ?? [];
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
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) {
|
if (profiles.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -31,6 +55,26 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<h1>Profiles</h1>
|
<h1>Profiles</h1>
|
||||||
</header>
|
</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
|
<EmptyState
|
||||||
icon={<Icon name="users" size={30} />}
|
icon={<Icon name="users" size={30} />}
|
||||||
title="No profiles yet"
|
title="No profiles yet"
|
||||||
|
|
@ -77,6 +121,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
||||||
|
{notice && (
|
||||||
|
<p className="muted" role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="profile-grid">
|
<div className="profile-grid">
|
||||||
{profiles.map((profile) => (
|
{profiles.map((profile) => (
|
||||||
|
|
@ -85,7 +134,12 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
className={`profile-card${profile.is_active ? ' is-active' : ''}`}
|
className={`profile-card${profile.is_active ? ' is-active' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="profile-card-top">
|
<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">
|
<div className="profile-card-meta">
|
||||||
<h3>{profile.label}</h3>
|
<h3>{profile.label}</h3>
|
||||||
<code className="mono" title={profile.npub}>
|
<code className="mono" title={profile.npub}>
|
||||||
|
|
@ -99,6 +153,39 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
|
|
||||||
<div className="profile-card-actions">
|
<div className="profile-card-actions">
|
||||||
<CopyButton text={profile.npub} label="public key" />
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -131,7 +218,126 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
profile={revealTarget}
|
profile={revealTarget}
|
||||||
onClose={() => setRevealTarget(null)}
|
onClose={() => setRevealTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{pictureTarget && (
|
||||||
|
<PictureModal
|
||||||
|
target={pictureTarget}
|
||||||
|
onClose={() => setPictureTarget(null)}
|
||||||
|
onSaved={(message) => {
|
||||||
|
setNotice(message);
|
||||||
|
setPictureTarget(null);
|
||||||
|
}}
|
||||||
|
onError={setError}
|
||||||
|
onSavingChange={setPublishing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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,
|
AppState,
|
||||||
FeedItem,
|
FeedItem,
|
||||||
LinkPreview,
|
LinkPreview,
|
||||||
|
MetadataPublishReport,
|
||||||
PickedImage,
|
PickedImage,
|
||||||
ProfileSummary,
|
ProfileSummary,
|
||||||
PublishReport,
|
PublishReport,
|
||||||
|
|
@ -38,6 +39,8 @@ interface AppContextValue {
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
createProfile: (label: string) => Promise<ProfileSummary>;
|
createProfile: (label: string) => Promise<ProfileSummary>;
|
||||||
selectProfile: (npub: string) => Promise<void>;
|
selectProfile: (npub: string) => Promise<void>;
|
||||||
|
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
||||||
|
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
||||||
publishNote: (content: string) => Promise<PublishReport>;
|
publishNote: (content: string) => Promise<PublishReport>;
|
||||||
recordPublishFailure: (message: string, details?: string | null) => void;
|
recordPublishFailure: (message: string, details?: string | null) => void;
|
||||||
clearLastPublish: () => void;
|
clearLastPublish: () => void;
|
||||||
|
|
@ -62,6 +65,8 @@ interface AppContextValue {
|
||||||
signerDisconnect: () => Promise<SignerStatus>;
|
signerDisconnect: () => Promise<SignerStatus>;
|
||||||
signerStatus: () => Promise<SignerStatus>;
|
signerStatus: () => Promise<SignerStatus>;
|
||||||
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
|
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
|
||||||
|
deleteProfile: (npub: string) => Promise<ProfileSummary>;
|
||||||
|
undoDelete: () => Promise<ProfileSummary>;
|
||||||
copyText: (text: string) => Promise<void>;
|
copyText: (text: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,17 +106,34 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createProfile = useCallback(async (label: string): Promise<ProfileSummary> => {
|
const createProfile = useCallback(
|
||||||
const result = await api.createProfile(label);
|
async (label: string): Promise<ProfileSummary> => {
|
||||||
|
const result = await api.createProfile(label, state?.settings);
|
||||||
setState(result.state);
|
setState(result.state);
|
||||||
return result.profile;
|
return result.profile;
|
||||||
}, []);
|
},
|
||||||
|
[state?.settings],
|
||||||
|
);
|
||||||
|
|
||||||
const selectProfile = useCallback(async (npub: string) => {
|
const selectProfile = useCallback(async (npub: string) => {
|
||||||
const fresh = await api.selectProfile(npub);
|
const fresh = await api.selectProfile(npub);
|
||||||
setState(fresh);
|
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 publishNote = useCallback(async (content: string): Promise<PublishReport> => {
|
||||||
const report = await api.publishNote(content);
|
const report = await api.publishNote(content);
|
||||||
setLastPublish({ report, error: null, details: null, at: Date.now() });
|
setLastPublish({ report, error: null, details: null, at: Date.now() });
|
||||||
|
|
@ -186,6 +208,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
return api.signerApprove(id, approved);
|
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), []);
|
const copyText = useCallback((text: string) => api.copyText(text), []);
|
||||||
|
|
||||||
useThemeSync(state?.settings.theme);
|
useThemeSync(state?.settings.theme);
|
||||||
|
|
@ -221,6 +246,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
signerDisconnect,
|
signerDisconnect,
|
||||||
signerStatus,
|
signerStatus,
|
||||||
signerApprove,
|
signerApprove,
|
||||||
|
deleteProfile,
|
||||||
|
undoDelete,
|
||||||
|
publishProfileMetadata,
|
||||||
|
setProfilePicture,
|
||||||
copyText,
|
copyText,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
@ -231,7 +260,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
refresh,
|
refresh,
|
||||||
createProfile,
|
createProfile,
|
||||||
selectProfile,
|
selectProfile,
|
||||||
|
publishProfileMetadata,
|
||||||
|
setProfilePicture,
|
||||||
publishNote,
|
publishNote,
|
||||||
|
deleteProfile,
|
||||||
|
undoDelete,
|
||||||
recordPublishFailure,
|
recordPublishFailure,
|
||||||
clearLastPublish,
|
clearLastPublish,
|
||||||
feedGet,
|
feedGet,
|
||||||
|
|
|
||||||
|
|
@ -916,6 +916,16 @@ select {
|
||||||
font-size: 21px;
|
font-size: 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-picture {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-picture img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.relay-status-list {
|
.relay-status-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
35
src/app.rs
35
src/app.rs
|
|
@ -35,6 +35,9 @@ pub struct AppStateView {
|
||||||
pub active_profile: Option<ProfileSummary>,
|
pub active_profile: Option<ProfileSummary>,
|
||||||
pub profiles: Vec<ProfileSummary>,
|
pub profiles: Vec<ProfileSummary>,
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
|
/// Recently deleted profiles, newest last, for undo.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub undo_history: Vec<ProfileSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -110,6 +113,7 @@ impl App {
|
||||||
public_key: restored.npub.clone(),
|
public_key: restored.npub.clone(),
|
||||||
secret_key: "".to_string(),
|
secret_key: "".to_string(),
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
|
picture: None,
|
||||||
};
|
};
|
||||||
self.vault.profiles.push(stored);
|
self.vault.profiles.push(stored);
|
||||||
// If no active profile, this restored one becomes active
|
// If no active profile, this restored one becomes active
|
||||||
|
|
@ -234,6 +238,7 @@ impl App {
|
||||||
active_profile: profiles::active_summary(&self.vault),
|
active_profile: profiles::active_summary(&self.vault),
|
||||||
profiles: profiles::summaries(&self.vault),
|
profiles: profiles::summaries(&self.vault),
|
||||||
settings: self.settings.clone(),
|
settings: self.settings.clone(),
|
||||||
|
undo_history: self.undo_history.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -274,17 +279,26 @@ mod tests {
|
||||||
use crate::errors::ErrorKind;
|
use crate::errors::ErrorKind;
|
||||||
use crate::profiles;
|
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 {
|
fn plaintext_vault() -> Vault {
|
||||||
let mut vault = Vault::empty();
|
let mut vault = Vault::empty();
|
||||||
profiles::create_profile(&mut vault, "Alice".to_string(), None).unwrap();
|
profiles::create_profile(&mut vault, "Alice".to_string(), None, &offline_settings())
|
||||||
profiles::create_profile(&mut vault, "Bob".to_string(), None).unwrap();
|
.unwrap();
|
||||||
|
profiles::create_profile(&mut vault, "Bob".to_string(), None, &offline_settings()).unwrap();
|
||||||
vault
|
vault
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_app() -> App {
|
fn sample_app() -> App {
|
||||||
App {
|
App {
|
||||||
vault: plaintext_vault(),
|
vault: plaintext_vault(),
|
||||||
settings: Settings::default(),
|
settings: offline_settings(),
|
||||||
unlock_key: None,
|
unlock_key: None,
|
||||||
undo_history: Vec::new(),
|
undo_history: Vec::new(),
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +368,12 @@ mod tests {
|
||||||
app.lock();
|
app.lock();
|
||||||
|
|
||||||
let key = app.vault_key().copied();
|
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");
|
.expect_err("locked vault must reject new profiles");
|
||||||
assert_eq!(err.kind(), ErrorKind::VaultLocked);
|
assert_eq!(err.kind(), ErrorKind::VaultLocked);
|
||||||
}
|
}
|
||||||
|
|
@ -366,7 +385,13 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let key = app.vault_key().copied();
|
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();
|
let created = app.vault.profiles.last().unwrap();
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
|
|
|
||||||
34
src/ipc.rs
34
src/ipc.rs
|
|
@ -38,6 +38,17 @@ pub enum Request {
|
||||||
SelectProfile {
|
SelectProfile {
|
||||||
npub: String,
|
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 {
|
PublishNote {
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
|
|
@ -282,7 +293,8 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
Request::CreateProfile { label } => {
|
Request::CreateProfile { label } => {
|
||||||
let label = normalise_label(&label);
|
let label = normalise_label(&label);
|
||||||
let key = app.vault_key().copied();
|
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()?;
|
app.save_vault()?;
|
||||||
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
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()))
|
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 } => {
|
Request::PublishNote { content } => {
|
||||||
let report =
|
let report =
|
||||||
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
||||||
|
|
|
||||||
62
src/main.rs
62
src/main.rs
|
|
@ -19,6 +19,8 @@ Commands:
|
||||||
list List stored profiles (no secret keys)
|
list List stored profiles (no secret keys)
|
||||||
switch <npub> Select the active profile
|
switch <npub> Select the active profile
|
||||||
publish <npub> <content> Publish a text note as a specific 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);
|
feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50);
|
||||||
--contacts filters to the active profile's contacts
|
--contacts filters to the active profile's contacts
|
||||||
relays list List configured relays
|
relays list List configured relays
|
||||||
|
|
@ -31,6 +33,8 @@ Commands:
|
||||||
settings set theme <light|dark|system>
|
settings set theme <light|dark|system>
|
||||||
settings set confirm <true|false>
|
settings set confirm <true|false>
|
||||||
settings set shorten <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)
|
set-password Encrypt the vault with a password (or change it)
|
||||||
remove-password Remove the vault password (keys back to plaintext)
|
remove-password Remove the vault password (keys back to plaintext)
|
||||||
unlock Verify the vault password for this process
|
unlock Verify the vault password for this process
|
||||||
|
|
@ -64,6 +68,8 @@ async fn main() -> ExitCode {
|
||||||
"list" => cli_list(),
|
"list" => cli_list(),
|
||||||
"switch" => cli_switch(&args),
|
"switch" => cli_switch(&args),
|
||||||
"publish" => cli_publish(&args).await,
|
"publish" => cli_publish(&args).await,
|
||||||
|
"publish-name" => cli_publish_name(&args),
|
||||||
|
"set-picture" => cli_set_picture(&args),
|
||||||
"feed" => cli_feed(&args).await,
|
"feed" => cli_feed(&args).await,
|
||||||
"relays" => cli_relays(&args).await,
|
"relays" => cli_relays(&args).await,
|
||||||
"settings" => cli_settings(&args),
|
"settings" => cli_settings(&args),
|
||||||
|
|
@ -73,6 +79,8 @@ async fn main() -> ExitCode {
|
||||||
"show-secret" => cli_show_secret(&args),
|
"show-secret" => cli_show_secret(&args),
|
||||||
"signer" => cli_signer(&args).await,
|
"signer" => cli_signer(&args).await,
|
||||||
"info" => cli_info(),
|
"info" => cli_info(),
|
||||||
|
"delete-profile" => cli_delete_profile(&args),
|
||||||
|
"undo-delete" => cli_undo_delete(),
|
||||||
"help" | "--help" | "-h" => {
|
"help" | "--help" | "-h" => {
|
||||||
println!("{USAGE}");
|
println!("{USAGE}");
|
||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
|
|
@ -107,7 +115,7 @@ fn cli_create(args: &[String]) -> Result<String, AppError> {
|
||||||
.unwrap_or_else(|| "New Profile".to_string());
|
.unwrap_or_else(|| "New Profile".to_string());
|
||||||
let mut app = load_app_with_unlock()?;
|
let mut app = load_app_with_unlock()?;
|
||||||
let key = app.vault_key().copied();
|
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()?;
|
app.save_vault()?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"Created profile \"{}\": {}",
|
"Created profile \"{}\": {}",
|
||||||
|
|
@ -159,6 +167,56 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
|
||||||
Ok(lines.join("\n"))
|
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> {
|
async fn cli_feed(args: &[String]) -> Result<String, AppError> {
|
||||||
let mut contacts = false;
|
let mut contacts = false;
|
||||||
let mut rest = &args[2..];
|
let mut rest = &args[2..];
|
||||||
|
|
@ -495,6 +553,7 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary
|
||||||
npub: stored.public_key,
|
npub: stored.public_key,
|
||||||
created_at: stored.created_at,
|
created_at: stored.created_at,
|
||||||
is_active: false,
|
is_active: false,
|
||||||
|
picture: stored.picture,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,6 +586,7 @@ fn cli_undo_delete() -> Result<String, AppError> {
|
||||||
public_key: restored.npub.clone(),
|
public_key: restored.npub.clone(),
|
||||||
secret_key: "".to_string(),
|
secret_key: "".to_string(),
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
|
picture: None,
|
||||||
};
|
};
|
||||||
app.vault.profiles.push(stored);
|
app.vault.profiles.push(stored);
|
||||||
if app.vault.active_profile.is_none() {
|
if app.vault.active_profile.is_none() {
|
||||||
|
|
|
||||||
365
src/profiles.rs
365
src/profiles.rs
|
|
@ -1,11 +1,20 @@
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::time::Duration;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::crypto::VaultKey;
|
use crate::crypto::VaultKey;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
use crate::publish::RelayFailure;
|
||||||
|
use crate::relays;
|
||||||
|
use crate::settings::Settings;
|
||||||
use crate::vault::{unix_timestamp, StoredProfile, Vault};
|
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.
|
/// A safe view of a profile that contains no secret key material.
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct ProfileSummary {
|
pub struct ProfileSummary {
|
||||||
|
|
@ -15,6 +24,8 @@ pub struct ProfileSummary {
|
||||||
/// Unix timestamp of creation.
|
/// Unix timestamp of creation.
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
pub is_active: bool,
|
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
|
/// 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,
|
vault: &mut Vault,
|
||||||
label: String,
|
label: String,
|
||||||
key: Option<&VaultKey>,
|
key: Option<&VaultKey>,
|
||||||
|
settings: &Settings,
|
||||||
) -> Result<ProfileSummary, AppError> {
|
) -> Result<ProfileSummary, AppError> {
|
||||||
if vault.is_encrypted() && key.is_none() {
|
if vault.is_encrypted() && key.is_none() {
|
||||||
return Err(AppError::vault_locked());
|
return Err(AppError::vault_locked());
|
||||||
|
|
@ -63,6 +75,7 @@ pub fn create_profile(
|
||||||
public_key: public_key.clone(),
|
public_key: public_key.clone(),
|
||||||
secret_key: stored_secret,
|
secret_key: stored_secret,
|
||||||
created_at,
|
created_at,
|
||||||
|
picture: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_active = vault.active_profile.is_none();
|
let is_active = vault.active_profile.is_none();
|
||||||
|
|
@ -72,14 +85,243 @@ pub fn create_profile(
|
||||||
|
|
||||||
vault.profiles.push(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 {
|
Ok(ProfileSummary {
|
||||||
label,
|
label,
|
||||||
npub: public_key,
|
npub: public_key,
|
||||||
created_at,
|
created_at,
|
||||||
is_active,
|
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
|
/// Safe summaries of every stored profile, newest last. Never includes
|
||||||
/// secret keys.
|
/// secret keys.
|
||||||
pub fn summaries(vault: &Vault) -> Vec<ProfileSummary> {
|
pub fn summaries(vault: &Vault) -> Vec<ProfileSummary> {
|
||||||
|
|
@ -106,6 +348,7 @@ fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary {
|
||||||
npub: profile.public_key.clone(),
|
npub: profile.public_key.clone(),
|
||||||
created_at: profile.created_at,
|
created_at: profile.created_at,
|
||||||
is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()),
|
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(),
|
public_key: "npub1alice".to_string(),
|
||||||
secret_key: "00".repeat(32),
|
secret_key: "00".repeat(32),
|
||||||
created_at: 1,
|
created_at: 1,
|
||||||
|
picture: None,
|
||||||
});
|
});
|
||||||
vault.profiles.push(StoredProfile {
|
vault.profiles.push(StoredProfile {
|
||||||
label: "Bob".to_string(),
|
label: "Bob".to_string(),
|
||||||
public_key: "npub1bob".to_string(),
|
public_key: "npub1bob".to_string(),
|
||||||
secret_key: "11".repeat(32),
|
secret_key: "11".repeat(32),
|
||||||
created_at: 2,
|
created_at: 2,
|
||||||
|
picture: None,
|
||||||
});
|
});
|
||||||
vault
|
vault
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings with no relays so tests never touch the network.
|
||||||
|
fn offline_settings() -> Settings {
|
||||||
|
Settings {
|
||||||
|
relays: Vec::new(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_profile_generates_valid_keys() {
|
fn create_profile_generates_valid_keys() {
|
||||||
let mut vault = Vault::empty();
|
let mut vault = Vault::empty();
|
||||||
let summary =
|
let summary = create_profile(&mut vault, "Newbie".to_string(), None, &offline_settings())
|
||||||
create_profile(&mut vault, "Newbie".to_string(), None).expect("should create");
|
.expect("should create");
|
||||||
assert!(summary.npub.starts_with("npub1"));
|
assert!(summary.npub.starts_with("npub1"));
|
||||||
assert_eq!(summary.label, "Newbie");
|
assert_eq!(summary.label, "Newbie");
|
||||||
assert_eq!(vault.profiles.len(), 1);
|
assert_eq!(vault.profiles.len(), 1);
|
||||||
|
|
@ -252,7 +505,8 @@ mod tests {
|
||||||
fn create_profile_keeps_existing_active() {
|
fn create_profile_keeps_existing_active() {
|
||||||
let mut vault = populated_vault();
|
let mut vault = populated_vault();
|
||||||
vault.active_profile = Some("npub1alice".to_string());
|
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!(!summary.is_active);
|
||||||
assert_eq!(vault.active_profile.as_deref(), Some("npub1alice"));
|
assert_eq!(vault.active_profile.as_deref(), Some("npub1alice"));
|
||||||
}
|
}
|
||||||
|
|
@ -325,7 +579,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn reveal_returns_hex_and_nsec_for_plaintext_vault() {
|
fn reveal_returns_hex_and_nsec_for_plaintext_vault() {
|
||||||
let mut vault = Vault::empty();
|
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");
|
let revealed = reveal_secret_key(&vault, &summary.npub, None).expect("must reveal");
|
||||||
assert_eq!(revealed.hex.len(), 64, "hex secret is 32 bytes");
|
assert_eq!(revealed.hex.len(), 64, "hex secret is 32 bytes");
|
||||||
|
|
@ -342,7 +597,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn reveal_decrypts_an_encrypted_profile_when_unlocked() {
|
fn reveal_decrypts_an_encrypted_profile_when_unlocked() {
|
||||||
let mut vault = Vault::empty();
|
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 plaintext = vault.profiles[0].secret_key.clone();
|
||||||
|
|
||||||
let salt = crate::crypto::generate_salt().unwrap();
|
let salt = crate::crypto::generate_salt().unwrap();
|
||||||
|
|
@ -374,7 +630,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn reveal_rejects_a_locked_vault() {
|
fn reveal_rejects_a_locked_vault() {
|
||||||
let mut vault = Vault::empty();
|
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 {
|
vault.crypto = Some(VaultCrypto {
|
||||||
kdf: KdfParams {
|
kdf: KdfParams {
|
||||||
algorithm: "argon2id".to_string(),
|
algorithm: "argon2id".to_string(),
|
||||||
|
|
@ -404,6 +661,101 @@ mod tests {
|
||||||
assert_eq!(profile_label(&vault, "npub1ghost"), None);
|
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.
|
/// Delete a profile by npub, returning the deleted profile for undo.
|
||||||
/// The vault must not be encrypted, or the key must be provided.
|
/// The vault must not be encrypted, or the key must be provided.
|
||||||
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
|
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
|
||||||
|
|
@ -422,6 +774,7 @@ mod tests {
|
||||||
npub: stored.public_key,
|
npub: stored.public_key,
|
||||||
created_at: stored.created_at,
|
created_at: stored.created_at,
|
||||||
is_active: false,
|
is_active: false,
|
||||||
|
picture: stored.picture,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ async fn publish_with_keys(
|
||||||
/// The underlying error types come from transitive dependencies, so the
|
/// The underlying error types come from transitive dependencies, so the
|
||||||
/// classification is based on the rendered message rather than brittle enum
|
/// classification is based on the rendered message rather than brittle enum
|
||||||
/// matching across versions.
|
/// 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 technical = err.to_string();
|
||||||
let lower = technical.to_lowercase();
|
let lower = technical.to_lowercase();
|
||||||
let concise = if lower.contains("timed out") || lower.contains("timeout") {
|
let concise = if lower.contains("timed out") || lower.contains("timeout") {
|
||||||
|
|
@ -334,7 +334,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_with_no_enabled_relays_errors() {
|
fn publish_with_no_enabled_relays_errors() {
|
||||||
let mut vault = Vault::empty();
|
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 settings = settings_with_no_relays();
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
let err = runtime
|
let err = runtime
|
||||||
|
|
@ -346,7 +352,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_with_invalid_stored_key_errors() {
|
fn publish_with_invalid_stored_key_errors() {
|
||||||
let mut vault = Vault::empty();
|
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();
|
vault.profiles[0].secret_key = "zz-not-hex".to_string();
|
||||||
let settings = settings_with_no_relays();
|
let settings = settings_with_no_relays();
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
|
@ -359,7 +371,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_locked_encrypted_vault_errors() {
|
fn publish_locked_encrypted_vault_errors() {
|
||||||
let mut vault = Vault::empty();
|
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 {
|
vault.crypto = Some(crate::vault::VaultCrypto {
|
||||||
kdf: crate::vault::KdfParams {
|
kdf: crate::vault::KdfParams {
|
||||||
algorithm: "argon2id".to_string(),
|
algorithm: "argon2id".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,21 @@ pub async fn nip98_authorization(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::settings::Settings;
|
||||||
use crate::vault::Vault;
|
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 {
|
fn vault_with_profile() -> Vault {
|
||||||
let mut vault = Vault::empty();
|
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
|
vault
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ pub struct StoredProfile {
|
||||||
pub secret_key: String,
|
pub secret_key: String,
|
||||||
/// Unix timestamp of creation.
|
/// Unix timestamp of creation.
|
||||||
pub created_at: u64,
|
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
|
/// KDF parameters that encrypted a vault. Stored so future key-derivation
|
||||||
|
|
@ -412,6 +416,7 @@ mod tests {
|
||||||
public_key: "npub1test".to_string(),
|
public_key: "npub1test".to_string(),
|
||||||
secret_key: "00ff".to_string(),
|
secret_key: "00ff".to_string(),
|
||||||
created_at: 1_700_000_000,
|
created_at: 1_700_000_000,
|
||||||
|
picture: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue