use super::*;
const PROFILE_METADATA_FETCH_TIMEOUT_SECS: u64 = 5;
impl AppCore {
pub(super) fn fetch_missing_profile_metadata(
&mut self,
owner_input: &str,
reason: &'static str,
) -> bool {
let Ok((owner_hex, owner_pubkey)) = parse_peer_input(owner_input) else {
return false;
};
self.fetch_missing_profile_metadata_for_owner(owner_hex, owner_pubkey, reason)
}
fn fetch_missing_profile_metadata_for_owner(
&mut self,
owner_hex: String,
owner_pubkey: PublicKey,
reason: &'static str,
) -> bool {
let is_local_owner = self
.logged_in
.as_ref()
.is_some_and(|logged_in| logged_in.owner_pubkey == owner_pubkey);
if is_group_chat_id(&owner_hex)
|| (!is_local_owner && self.has_cached_profile_metadata(&owner_hex))
{
return false;
}
let Some((client, relay_urls)) = self
.logged_in
.as_ref()
.filter(|logged_in| !logged_in.relay_urls.is_empty())
.map(|logged_in| (logged_in.client.clone(), logged_in.relay_urls.clone()))
else {
return false;
};
if !self
.profile_metadata_fetch_inflight
.insert(owner_hex.clone())
{
self.push_debug_log(
"profile.metadata.fetch.skip",
format!("reason={reason} owner={owner_hex} in_flight=true"),
);
return false;
}
self.push_debug_log(
"profile.metadata.fetch",
format!("reason={reason} owner={owner_hex}"),
);
let tx = self.core_sender.clone();
let discovery = self.discovery_relays.clone();
let scope = self.cold_read_scope();
self.runtime.spawn(async move {
ensure_session_relays_configured(&client, &relay_urls).await;
connect_client_with_timeout(&client, Duration::from_secs(5)).await;
let filter = Filter::new().kind(Kind::Metadata).author(owner_pubkey);
match super::protocol::fetch_events_for_filters(
&client,
&discovery,
&scope,
vec![filter],
Duration::from_secs(PROFILE_METADATA_FETCH_TIMEOUT_SECS),
)
.await
{
Ok(events) => {
let _ = tx.send(CoreMsg::Internal(Box::new(
InternalEvent::ProfileMetadataFetchFinished {
owner_pubkey_hex: owner_hex,
events,
error: None,
},
)));
}
Err(error) => {
let _ = tx.send(CoreMsg::Internal(Box::new(
InternalEvent::ProfileMetadataFetchFinished {
owner_pubkey_hex: owner_hex,
events: Vec::new(),
error: Some(error.to_string()),
},
)));
}
}
});
true
}
fn has_cached_profile_metadata(&self, owner_hex: &str) -> bool {
self.owner_profiles.get(owner_hex).is_some_and(|profile| {
profile.profile_label().is_some()
|| profile.picture.is_some()
|| profile.about.is_some()
|| profile.extra_metadata_json.trim() != "{}"
|| !profile.extra_tags.is_empty()
})
}
fn next_local_profile_timestamp(&self, owner_hex: &str) -> Timestamp {
let previous = self
.owner_profiles
.get(owner_hex)
.map_or(0, |profile| profile.updated_at_secs);
Timestamp::from_secs(unix_now().get().max(previous.saturating_add(1)))
}
pub(super) fn set_local_profile_name(&mut self, name: &str) {
let picture_url = self
.logged_in
.as_ref()
.and_then(|logged_in| {
self.owner_profiles
.get(&logged_in.owner_pubkey.to_string())
.and_then(|profile| profile.picture.as_deref())
})
.map(str::to_string);
let about = self
.logged_in
.as_ref()
.and_then(|logged_in| {
self.owner_profiles
.get(&logged_in.owner_pubkey.to_string())
.and_then(|profile| profile.about.as_deref())
})
.map(str::to_string);
self.set_local_profile_metadata(name, picture_url.as_deref(), about.as_deref());
}
pub(super) fn set_local_profile_metadata(
&mut self,
name: &str,
picture_url: Option<&str>,
about: Option<&str>,
) {
let Some(local_owner_hex) = self
.logged_in
.as_ref()
.map(|logged_in| logged_in.owner_pubkey.to_string())
else {
return;
};
let Some(record) = build_owner_profile_record(name, picture_url, about) else {
return;
};
let mut record = record;
record.updated_at_secs = self
.next_local_profile_timestamp(&local_owner_hex)
.as_secs();
if let Some(existing) = self.owner_profiles.get(&local_owner_hex) {
record.contact_memory = existing.contact_memory.clone();
record.nickname = existing.nickname.clone();
record.contact_note = existing.contact_note.clone();
record.contact_updated_at_ms = existing.contact_updated_at_ms;
record.extra_metadata_json = existing.extra_metadata_json.clone();
record.extra_tags = existing.extra_tags.clone();
}
self.owner_profiles.insert(local_owner_hex.clone(), record);
self.push_debug_log("profile.local.set", format!("owner={local_owner_hex}"));
self.persist_best_effort();
}
pub(super) fn update_profile_metadata(
&mut self,
name: &str,
picture_url: Option<&str>,
about: Option<&str>,
) {
let trimmed = name.trim();
if trimmed.is_empty() {
self.state.toast = Some("Display name is required.".to_string());
self.emit_state();
return;
}
let Some(logged_in) = self.logged_in.as_ref() else {
self.state.toast = Some("Create or restore a profile first.".to_string());
self.emit_state();
return;
};
if logged_in.owner_keys.is_none() {
self.state.toast = Some("Owner key is required to edit profile.".to_string());
self.emit_state();
return;
}
let normalized_picture_url = match normalize_profile_field(picture_url.map(str::to_string))
{
Some(url) if normalize_profile_url(Some(url.clone())).is_none() => {
self.state.toast =
Some("Profile picture must be an http or https URL.".to_string());
self.emit_state();
return;
}
value => value,
};
self.set_local_profile_metadata(trimmed, normalized_picture_url.as_deref(), about);
self.republish_local_identity_artifacts();
self.rebuild_state();
self.emit_state();
}
pub(super) fn delete_profile_metadata(&mut self) {
let Some((owner_hex, owner_keys)) = self.logged_in.as_ref().and_then(|logged_in| {
logged_in
.owner_keys
.clone()
.map(|keys| (logged_in.owner_pubkey.to_hex(), keys))
}) else {
self.state.toast = Some("Secret key is required to delete profile.".to_string());
self.emit_state();
return;
};
let created_at = self.next_local_profile_timestamp(&owner_hex);
self.owner_profiles.remove(&owner_hex);
self.persist_best_effort();
match EventBuilder::new(Kind::Metadata, "{}")
.custom_created_at(created_at)
.sign_with_keys(&owner_keys)
{
Ok(event) => {
if self.publish_runtime_event(event, "profile-delete", None) {
self.state.toast = Some("Profile deleted".to_string());
} else {
self.state.toast = Some("Profile could not be deleted.".to_string());
}
}
Err(error) => {
self.push_debug_log("profile.delete.error", error.to_string());
self.state.toast = Some("Profile could not be deleted.".to_string());
}
}
self.rebuild_state();
self.emit_state();
}
pub(super) fn upload_profile_picture(&mut self, file_path: &str) {
self.push_debug_log("profile.picture.upload.start", format!("path={file_path}"));
let Some(logged_in) = self.logged_in.as_ref() else {
self.push_debug_log("profile.picture.upload.skip", "no_logged_in".to_string());
self.state.toast = Some("Create or restore a profile first.".to_string());
self.emit_state();
return;
};
let Some(owner_keys) = logged_in.owner_keys.as_ref() else {
self.push_debug_log("profile.picture.upload.skip", "no_owner_keys".to_string());
self.state.toast = Some("Owner key is required to edit profile.".to_string());
self.emit_state();
return;
};
let path = PathBuf::from(file_path.trim());
if !path.is_file() {
self.push_debug_log(
"profile.picture.upload.skip",
format!("missing_file={file_path}"),
);
self.state.toast = Some("Profile picture was not found.".to_string());
self.emit_state();
return;
}
let secret_hex = owner_keys.secret_key().to_secret_hex();
let sender = self.core_sender.clone();
self.state.busy.uploading_attachment = true;
self.emit_state();
self.runtime.spawn(async move {
let result = upload_profile_picture_to_hashtree(&secret_hex, &path)
.await
.map_err(|error| error.to_string());
let _ = sender.send(CoreMsg::Internal(Box::new(
InternalEvent::ProfilePictureUploadFinished { result },
)));
});
}
pub(super) fn handle_profile_picture_upload_finished(
&mut self,
result: Result<String, String>,
) {
self.state.busy.uploading_attachment = false;
match result {
Ok(picture_url) => {
self.push_debug_log("profile.picture.upload.ok", format!("url={picture_url}"));
let name = self
.state
.account
.as_ref()
.map(|account| account.display_name.clone())
.unwrap_or_else(|| "Iris".to_string());
let about = self
.state
.account
.as_ref()
.and_then(|account| account.about.clone());
self.update_profile_metadata(&name, Some(&picture_url), about.as_deref());
}
Err(error) => {
self.push_debug_log("profile.picture.upload.error", error.clone());
self.state.toast = Some(format!("Profile picture upload failed: {error}"));
self.emit_state();
}
}
}
pub(super) fn apply_profile_metadata_event(&mut self, event: &Event) -> bool {
let owner_hex = event.pubkey.to_hex();
let extra_tags: Vec<Vec<String>> = event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect();
let Some(mut record) =
parse_owner_profile_record(&event.content, extra_tags, event.created_at.as_secs())
else {
return false;
};
if let Some(existing) = self.owner_profiles.get(&owner_hex) {
if existing.updated_at_secs > record.updated_at_secs {
return false;
}
record.contact_memory = existing.contact_memory.clone();
record.nickname = existing.nickname.clone();
record.contact_note = existing.contact_note.clone();
record.contact_updated_at_ms = existing.contact_updated_at_ms;
}
if !self.cache_device_sync_profile(event) {
return false;
}
record.source_event_id = Some(event.id.to_hex());
self.remember_contact_name(&owner_hex);
self.cache_local_fips_identity(event);
self.owner_profiles.insert(owner_hex.clone(), record);
self.remember_contact_name(&owner_hex);
self.push_debug_log("relay.metadata", format!("owner={owner_hex}"));
self.mark_mobile_push_dirty();
if !self.fips_nearby_links.is_empty() {
self.emit_fips_nearby_peers();
}
true
}
pub(super) fn owner_display_name(&self, owner_hex: &str) -> Option<String> {
let profile = self.owner_profiles.get(owner_hex);
[
profile.and_then(|p| p.nickname.as_deref()),
profile.and_then(|p| p.contact_memory.accepted_name.as_deref()),
self.user_discovery
.users
.get(owner_hex)
.and_then(|p| p.petname.as_deref()),
profile.and_then(|p| p.display_name.as_deref()),
profile.and_then(|p| p.name.as_deref()),
]
.into_iter()
.flatten()
.find_map(|name| profile_name_for_identity(name, owner_hex))
}
pub(super) fn owner_nickname(&self, owner_hex: &str) -> Option<String> {
self.owner_profiles
.get(owner_hex)
.and_then(|profile| profile.nickname.clone())
}
pub(super) fn owner_profile_name(&self, owner_hex: &str) -> Option<String> {
let profile = self.owner_profiles.get(owner_hex)?;
[profile.display_name.as_deref(), profile.name.as_deref()]
.into_iter()
.flatten()
.find_map(|name| profile_name_for_identity(name, owner_hex))
}
pub(super) fn owner_display_label(&self, owner_hex: &str) -> String {
self.owner_display_name(owner_hex)
.unwrap_or_else(|| fallback_profile_name_for_identity(owner_hex))
}
pub(super) fn direct_chat_display_label(&self, owner_hex: &str) -> String {
if self
.logged_in
.as_ref()
.is_some_and(|session| session.owner_pubkey.to_hex() == owner_hex)
{
crate::search::NOTE_TO_SELF.to_string()
} else {
self.owner_display_label(owner_hex)
}
}
pub(super) fn owner_picture_url(&self, owner_hex: &str) -> Option<String> {
self.owner_profiles
.get(owner_hex)
.and_then(|profile| profile.picture.clone())
}
pub(super) fn owner_about(&self, owner_hex: &str) -> Option<String> {
self.owner_profiles
.get(owner_hex)
.and_then(|profile| profile.about.clone())
}
pub(super) fn owner_secondary_identifier(&self, owner_hex: &str) -> Option<String> {
let nickname = self.owner_nickname(owner_hex)?;
let profile_name = self.owner_profile_name(owner_hex)?;
(profile_name != nickname).then_some(profile_name)
}
}
pub(crate) fn fallback_profile_name_for_identity(identity: &str) -> String {
const ADJECTIVES: [&str; 12] = [
"Amber", "Bright", "Calm", "Clear", "Golden", "Lunar", "Nova", "Quiet", "Silver", "Solar",
"Velvet", "Wild",
];
const NOUNS: [&str; 12] = [
"Badger", "Bear", "Dolphin", "Falcon", "Fox", "Hare", "Otter", "Raven", "Robin", "Sparrow",
"Tiger", "Wolf",
];
let normalized = PublicKey::parse(identity.trim())
.ok()
.map(|key| key.to_hex());
let trimmed = normalized.as_deref().unwrap_or_else(|| identity.trim());
if trimmed.is_empty() {
return "Quiet Otter".to_string();
}
let hash = trimmed.bytes().fold(0_u32, |hash, byte| {
hash.wrapping_mul(31).wrapping_add(byte as u32)
});
let adjective = ADJECTIVES
.get((hash as usize) % ADJECTIVES.len())
.copied()
.unwrap_or("Quiet");
let noun = NOUNS
.get(((hash as usize) / ADJECTIVES.len()) % NOUNS.len())
.copied()
.unwrap_or("Otter");
format!("{adjective} {noun}")
}
pub(crate) fn profile_name_for_identity(name: &str, identity: &str) -> Option<String> {
let name = name.trim();
if name.is_empty() {
return None;
}
let lower = name.to_lowercase();
let owner = PublicKey::parse(identity).ok();
let candidates = [
Some(identity.to_lowercase()),
owner.map(|key| key.to_hex()),
owner.and_then(|key| key.to_bech32().ok()),
];
let is_id = candidates.into_iter().flatten().any(|candidate| {
if lower == candidate {
return true;
}
let normalized = lower.replace('…', "...");
let Some((start, end)) = normalized.split_once("...") else {
return false;
};
start.len() >= 4
&& end.len() >= 4
&& candidate.starts_with(start)
&& candidate.ends_with(end)
});
(!is_id).then(|| name.to_string())
}
#[cfg(test)]
mod person_name_tests {
use super::*;
#[test]
fn animal_names_are_stable_across_identity_encodings() {
let hex = "ab".repeat(32);
let npub = PublicKey::from_hex(&hex).unwrap().to_bech32().unwrap();
assert_eq!(fallback_profile_name_for_identity(&hex), "Golden Hare");
assert_eq!(fallback_profile_name_for_identity(&npub), "Golden Hare");
assert_eq!(fallback_profile_name_for_identity(""), "Quiet Otter");
}
#[test]
fn cached_identifiers_are_not_profile_names_but_real_labels_are_preserved() {
let hex = "ab".repeat(32);
let npub = PublicKey::from_hex(&hex).unwrap().to_bech32().unwrap();
for value in [&hex, &npub, "abababab...abababab", "abababab…abababab", " "] {
assert_eq!(profile_name_for_identity(value, &hex), None);
}
for name in ["Alice", "Mum", "Golden Hare", "Amber Fox"] {
assert_eq!(profile_name_for_identity(name, &hex).as_deref(), Some(name));
}
}
#[test]
fn cached_identity_label_does_not_override_an_unnamed_contact_or_real_nickname() {
let dir = tempfile::tempdir().unwrap();
let mut core = AppCore::new(
flume::unbounded().0,
flume::unbounded().0,
dir.path().to_string_lossy().into_owned(),
Arc::new(RwLock::new(AppState::empty())),
);
let owner = "ab".repeat(32);
let mut profile = build_owner_profile_record("abababab…abababab", None, None).unwrap();
profile.contact_memory.accepted_name = Some(owner.clone());
core.owner_profiles.insert(owner.clone(), profile);
assert_eq!(core.owner_display_label(&owner), "Golden Hare");
assert_eq!(core.owner_profile_name(&owner), None);
assert_eq!(
core.owner_profiles[&owner]
.contact_memory
.accepted_name
.as_deref(),
Some(owner.as_str())
);
core.owner_profiles.get_mut(&owner).unwrap().nickname = Some("Mum".into());
assert_eq!(core.owner_display_label(&owner), "Mum");
core.owner_profiles.get_mut(&owner).unwrap().nickname = Some("Golden Hare".into());
assert_eq!(
core.owner_display_name(&owner).as_deref(),
Some("Golden Hare")
);
}
}