Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,5 +296,6 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo {
avatar_url: None,
about: None,
nip05_handle: None,
owner_pubkey: None,
}
}
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ pub struct ProfileInfo {
pub avatar_url: Option<String>,
pub about: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
}

#[derive(Serialize, Deserialize)]
pub struct UserProfileSummaryInfo {
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
pub is_agent: bool,
}
Expand All @@ -38,6 +40,7 @@ pub struct UserSearchResultInfo {
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
pub is_agent: bool,
}
Expand Down
43 changes: 32 additions & 11 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,16 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator<Item = &'a [
})
}

/// Return true when a kind:0 profile carries a valid NIP-OA owner tag.
/// Return the owner pubkey from a valid NIP-OA owner tag on a kind:0 profile.
///
/// NIP-OA marks an agent identity by having the owner sign an `auth` tag for
/// the agent pubkey. We verify the tag against the profile event author, not
/// against the owner, so a forged or stale marker does not turn a person into
/// an agent in mention search.
pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool {
pub(crate) fn profile_valid_oa_owner_pubkey(event: &Event) -> Option<String> {
let target_hex = event.pubkey.to_hex();
let Ok(target_pubkey) = nostr::PublicKey::from_hex(&target_hex) else {
return false;
return None;
};

for tag in event.tags.iter() {
Expand All @@ -75,12 +75,16 @@ pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool {
let Ok(json) = serde_json::to_string(slice) else {
continue;
};
if buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey).is_ok() {
return true;
if let Ok(owner_pubkey) = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey) {
return Some(owner_pubkey.to_hex());
}
}

false
None
}

pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool {
profile_valid_oa_owner_pubkey(event).is_some()
}

// ── kind:39000 / 39002 (NIP-29) ─────────────────────────────────────────────
Expand Down Expand Up @@ -299,6 +303,7 @@ pub fn profile_info_from_event(event: &Event) -> Result<ProfileInfo, String> {
avatar_url,
about,
nip05_handle,
owner_pubkey: profile_valid_oa_owner_pubkey(event),
})
}

Expand Down Expand Up @@ -326,6 +331,7 @@ pub fn users_batch_from_events(
let mut profiles = HashMap::new();
for (pk, ev) in &latest {
let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null);
let owner_pubkey = profile_valid_oa_owner_pubkey(ev);
let summary = UserProfileSummaryInfo {
display_name: v
.get("display_name")
Expand All @@ -334,7 +340,8 @@ pub fn users_batch_from_events(
.map(str::to_string),
avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string),
nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string),
is_agent: profile_has_valid_oa_owner(ev),
is_agent: owner_pubkey.is_some(),
owner_pubkey,
};
profiles.insert(pk.clone(), summary);
}
Expand Down Expand Up @@ -586,7 +593,7 @@ mod tests {
}

/// Build a kind:0 profile with a valid NIP-OA auth tag.
fn oa_profile_event(content: &str) -> Event {
fn oa_profile_event(content: &str) -> (Event, String) {
let agent_keys = Keys::generate();
let owner_keys = Keys::generate();
let agent_pubkey = agent_keys.public_key();
Expand All @@ -595,10 +602,11 @@ mod tests {
let tag_values: Vec<String> = serde_json::from_str(&tag_json).expect("parse auth tag json");
let auth_tag = Tag::parse(tag_values).expect("parse auth tag");

EventBuilder::new(Kind::Metadata, content)
let event = EventBuilder::new(Kind::Metadata, content)
.tags(vec![auth_tag])
.sign_with_keys(&agent_keys)
.expect("sign")
.expect("sign");
(event, owner_keys.public_key().to_hex())
}

#[test]
Expand Down Expand Up @@ -760,6 +768,15 @@ mod tests {
assert_eq!(p.about.as_deref(), Some("hi"));
assert_eq!(p.nip05_handle.as_deref(), Some("alice@x"));
assert_eq!(p.pubkey, e.pubkey.to_hex());
assert!(p.owner_pubkey.is_none());
}

#[test]
fn profile_info_extracts_valid_nip_oa_owner() {
let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#);
let p = profile_info_from_event(&event).unwrap();

assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str()));
}

#[test]
Expand Down Expand Up @@ -803,12 +820,16 @@ mod tests {

#[test]
fn users_batch_marks_valid_nip_oa_profiles_as_agents() {
let agent = oa_profile_event(r#"{"display_name":"Mira"}"#);
let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#);
let pubkey = agent.pubkey.to_hex();
let resp =
users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey));

assert!(resp.profiles[&pubkey].is_agent);
assert_eq!(
resp.profiles[&pubkey].owner_pubkey.as_deref(),
Some(owner_pubkey.as_str())
);
}

#[test]
Expand Down
12 changes: 10 additions & 2 deletions desktop/src-tauri/src/nostr_convert/user_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ use serde_json::Value;

use crate::models::{SearchUsersResponse, UserSearchResultInfo};

use super::profile_has_valid_oa_owner;
use super::profile_valid_oa_owner_pubkey;

/// Convert a single kind:0 event to a [`UserSearchResultInfo`].
pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo {
let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null);
let owner_pubkey = profile_valid_oa_owner_pubkey(ev);
UserSearchResultInfo {
pubkey: ev.pubkey.to_hex(),
display_name: v
Expand All @@ -19,7 +20,8 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo {
.map(str::to_string),
avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string),
nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string),
is_agent: profile_has_valid_oa_owner(ev),
is_agent: owner_pubkey.is_some(),
owner_pubkey,
}
}

Expand Down Expand Up @@ -224,9 +226,15 @@ mod tests {
#[test]
fn user_search_result_marks_valid_nip_oa_profile_as_agent() {
let event = oa_profile_event(r#"{"display_name":"Mira"}"#);
let owner_pubkey = event
.tags
.iter()
.find_map(|tag| tag.as_slice().get(1).cloned())
.expect("owner pubkey");
let result = user_search_result_from_event(&event);

assert_eq!(result.display_name.as_deref(), Some("Mira"));
assert_eq!(result.owner_pubkey.as_deref(), Some(owner_pubkey.as_str()));
assert!(result.is_agent);
}

Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,7 @@ export const ChannelPane = React.memo(function ChannelPane({
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onOpenProfile={onOpenProfilePanel}
onViewChange={onProfilePanelViewChange}
pubkey={profilePanelPubkey}
splitPaneClamp
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export function MembersSidebar({
? currentName
: (currentName ?? candidateName),
nip05Handle: current.nip05Handle ?? candidate.nip05Handle ?? null,
ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null,
isAgent: current.isAgent || candidate.isAgent,
});
};
Expand All @@ -265,6 +266,7 @@ export function MembersSidebar({
displayName: agent.name,
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
isAgent: true,
});
}
Expand All @@ -275,6 +277,7 @@ export function MembersSidebar({
displayName: agent.name,
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
isAgent: true,
});
}
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/profile/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function useProfileQuery(enabled = true) {
avatarUrl: cached.avatarUrl,
about: null,
nip05Handle: null,
ownerPubkey: null,
} satisfies Profile)
: undefined,
[cached, pubkey],
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/profile/lib/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function mergeCurrentProfileIntoLookup(
avatarUrl: currentProfile.avatarUrl,
nip05Handle: currentProfile.nip05Handle,
isAgent: profiles?.[normalizePubkey(currentProfile.pubkey)]?.isAgent,
ownerPubkey:
profiles?.[normalizePubkey(currentProfile.pubkey)]?.ownerPubkey ?? null,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function makeUser(overrides = {}) {
displayName: null,
isAgent: false,
nip05Handle: null,
ownerPubkey: null,
pubkey: "abcdef1234567890",
...overrides,
};
Expand Down
40 changes: 37 additions & 3 deletions desktop/src/features/profile/ui/UserProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type UserProfilePanelProps = {
layout?: "standalone" | "split";
onClose: () => void;
onOpenDm?: (pubkeys: string[]) => void;
onOpenProfile?: (pubkey: string) => void;
onResetWidth?: () => void;
onResizeStart?: (event: React.PointerEvent<HTMLButtonElement>) => void;
onViewChange: (
Expand Down Expand Up @@ -136,6 +137,7 @@ export function UserProfilePanel({
layout = "standalone",
onClose,
onOpenDm,
onOpenProfile,
onResetWidth,
onResizeStart,
onViewChange,
Expand Down Expand Up @@ -172,6 +174,8 @@ export function UserProfilePanel({
const { goChannel } = useAppNavigation();

const profile = profileQuery.data;
const ownerPubkey = profile?.ownerPubkey ?? null;
const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined);
const pubkeyLower = pubkey.toLowerCase();
const presenceStatus = presenceQuery.data?.[pubkeyLower];
const userStatus = userStatusQuery.data?.[pubkeyLower];
Expand Down Expand Up @@ -252,7 +256,16 @@ export function UserProfilePanel({

const displayName = profile?.displayName ?? truncatePubkey(pubkey);
const ownerHandle = React.useMemo(() => {
if (currentPubkey === undefined) {
if (ownerPubkey) {
const ownerProfile = ownerProfileQuery.data;
return (
ownerProfile?.nip05Handle?.trim() ||
ownerProfile?.displayName?.trim() ||
truncatePubkey(ownerPubkey)
);
}

if (currentPubkey === undefined || isOwner !== true) {
return null;
}

Expand All @@ -262,8 +275,22 @@ export function UserProfilePanel({
currentProfile?.displayName?.trim() ||
truncatePubkey(currentPubkey)
);
}, [currentProfileQuery.data, currentPubkey]);
const ownerDisplayName = ownerHandle ? `${ownerHandle} (you)` : null;
}, [
currentProfileQuery.data,
currentPubkey,
isOwner,
ownerProfileQuery.data,
ownerPubkey,
]);
const isCurrentUserOwner =
currentPubkey !== undefined &&
ownerPubkey !== null &&
ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
const ownerDisplayName = ownerHandle
? isCurrentUserOwner || (!ownerPubkey && isOwner === true)
? `${ownerHandle} (you)`
: ownerHandle
: null;
const panelTitle = VIEW_TITLES[view];
const memoryCount = memoryQuery.data
? (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length
Expand Down Expand Up @@ -334,8 +361,15 @@ export function UserProfilePanel({
memoriesLoading={memoryQuery.isLoading}
memoryCount={memoryCount}
ownerDisplayName={ownerDisplayName}
ownerAvatarUrl={ownerProfileQuery.data?.avatarUrl ?? null}
ownerHandle={ownerHandle}
ownerPubkey={ownerPubkey}
onOpenChannels={() => onViewChange("channels")}
onOpenOwner={
ownerPubkey && onOpenProfile
? () => onOpenProfile(ownerPubkey)
: undefined
}
onOpenMemories={() => onViewChange("memories")}
onOpenDm={onOpenDm}
presenceLoaded={presenceQuery.isSuccess}
Expand Down
Loading
Loading