mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(identity): show relay-verified profiles
This commit is contained in:
@@ -68,6 +68,12 @@ pub const KIND_LONG_FORM: u32 = 30023;
|
||||
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
|
||||
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
|
||||
pub const KIND_USER_STATUS: u32 = 30315;
|
||||
/// NIP-85: relay-signed trusted assertion about a user pubkey.
|
||||
///
|
||||
/// Buzz uses this standard user-subject assertion kind to project an active
|
||||
/// enterprise identity binding without exposing the binding's stable uid.
|
||||
/// The relay authors the event and keys it by the subject pubkey in `d`.
|
||||
pub const KIND_USER_TRUSTED_ASSERTION: u32 = 30382;
|
||||
/// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync.
|
||||
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
|
||||
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
|
||||
|
||||
@@ -16,14 +16,15 @@ use jsonwebtoken::{
|
||||
jwk::{Jwk, JwkSet},
|
||||
Algorithm, DecodingKey, Validation,
|
||||
};
|
||||
use nostr::{FromBech32, PublicKey};
|
||||
use nostr::{Event, EventBuilder, FromBech32, Kind, PublicKey, Tag, Timestamp};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use buzz_core::CommunityId;
|
||||
use buzz_core::{kind::KIND_USER_TRUSTED_ASSERTION, CommunityId};
|
||||
use buzz_db::event::EventQuery;
|
||||
use buzz_db::identity_binding::{BindIdentityResult, SOURCE_DB_BINDING, SOURCE_JWT_NPUB};
|
||||
|
||||
use crate::config::CorporateIdentityConfig;
|
||||
@@ -355,6 +356,26 @@ async fn enforce_corporate_identity_inner(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Err(error) = ensure_identity_assertion(
|
||||
state,
|
||||
community_id,
|
||||
signer,
|
||||
&claims.display_name,
|
||||
&service.config.issuer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// The binding remains the authorization authority. A projection
|
||||
// failure removes the verified affordance but must not lock an
|
||||
// otherwise authorized user out of the relay.
|
||||
warn!(
|
||||
signer = %signer.to_hex(),
|
||||
error = %error,
|
||||
"failed to publish corporate identity assertion"
|
||||
);
|
||||
metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "error")
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
debug!(
|
||||
uid = %claims.uid,
|
||||
@@ -379,6 +400,104 @@ async fn enforce_corporate_identity_inner(
|
||||
.await
|
||||
}
|
||||
|
||||
fn build_identity_assertion(
|
||||
relay_keypair: &nostr::Keys,
|
||||
subject: PublicKey,
|
||||
display_name: &str,
|
||||
issuer: &str,
|
||||
created_at: Timestamp,
|
||||
) -> Result<Event, String> {
|
||||
let subject = subject.to_hex();
|
||||
let tags = [
|
||||
Tag::parse(["d", subject.as_str()]),
|
||||
Tag::parse(["p", subject.as_str()]),
|
||||
Tag::parse(["verified", "corporate"]),
|
||||
Tag::parse(["display_name", display_name]),
|
||||
Tag::parse(["issuer", issuer]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("invalid corporate identity assertion tag: {error}"))?;
|
||||
|
||||
EventBuilder::new(Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "")
|
||||
.tags(tags)
|
||||
.custom_created_at(created_at)
|
||||
.sign_with_keys(relay_keypair)
|
||||
.map_err(|error| format!("failed to sign corporate identity assertion: {error}"))
|
||||
}
|
||||
|
||||
fn identity_assertion_matches(
|
||||
event: &Event,
|
||||
subject: &str,
|
||||
display_name: &str,
|
||||
issuer: &str,
|
||||
) -> bool {
|
||||
let has_tag = |name: &str, value: &str| {
|
||||
event.tags.iter().any(|tag| {
|
||||
let parts = tag.as_slice();
|
||||
parts.len() == 2 && parts[0] == name && parts[1] == value
|
||||
})
|
||||
};
|
||||
has_tag("d", subject)
|
||||
&& has_tag("p", subject)
|
||||
&& has_tag("verified", "corporate")
|
||||
&& has_tag("display_name", display_name)
|
||||
&& has_tag("issuer", issuer)
|
||||
}
|
||||
|
||||
async fn ensure_identity_assertion(
|
||||
state: &AppState,
|
||||
community_id: CommunityId,
|
||||
subject: PublicKey,
|
||||
display_name: &str,
|
||||
issuer: &str,
|
||||
) -> Result<(), String> {
|
||||
let subject_hex = subject.to_hex();
|
||||
let existing = state
|
||||
.db
|
||||
.query_events(&EventQuery {
|
||||
kinds: Some(vec![KIND_USER_TRUSTED_ASSERTION as i32]),
|
||||
pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()),
|
||||
d_tag: Some(subject_hex.clone()),
|
||||
global_only: true,
|
||||
limit: Some(1),
|
||||
..EventQuery::for_community(community_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_iter()
|
||||
.next();
|
||||
|
||||
if existing.as_ref().is_some_and(|stored| {
|
||||
identity_assertion_matches(&stored.event, &subject_hex, display_name, issuer)
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = Timestamp::now().as_secs();
|
||||
let created_at = existing
|
||||
.as_ref()
|
||||
.map(|stored| stored.event.created_at.as_secs().saturating_add(1))
|
||||
.unwrap_or(now)
|
||||
.max(now);
|
||||
let event = build_identity_assertion(
|
||||
&state.relay_keypair,
|
||||
subject,
|
||||
display_name,
|
||||
issuer,
|
||||
Timestamp::from(created_at),
|
||||
)?;
|
||||
|
||||
state
|
||||
.db
|
||||
.replace_parameterized_event(community_id, &event, &subject_hex, None)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "published")
|
||||
.increment(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enforce_delegated_corporate_identity(
|
||||
db: &buzz_db::Db,
|
||||
config: &CorporateIdentityConfig,
|
||||
@@ -598,6 +717,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_identity_projects_as_relay_signed_nip85_assertion() {
|
||||
let relay = Keys::generate();
|
||||
let subject = Keys::generate().public_key();
|
||||
let event = build_identity_assertion(
|
||||
&relay,
|
||||
subject,
|
||||
"Franco Sola",
|
||||
"cf-doorman-production",
|
||||
Timestamp::from(123),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(event.kind.as_u16() as u32, KIND_USER_TRUSTED_ASSERTION);
|
||||
assert_eq!(event.pubkey, relay.public_key());
|
||||
assert!(event.verify_id());
|
||||
assert!(event.verify_signature());
|
||||
assert!(identity_assertion_matches(
|
||||
&event,
|
||||
&subject.to_hex(),
|
||||
"Franco Sola",
|
||||
"cf-doorman-production"
|
||||
));
|
||||
assert!(
|
||||
!event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.as_slice().first().is_some_and(|name| name == "uid")),
|
||||
"the public assertion must not expose the stable corporate uid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_hmac_jwt_algorithms_in_allowlist() {
|
||||
assert!(!is_allowed_jwt_algorithm(Algorithm::HS256));
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use buzz_core_pkg::PresenceStatus;
|
||||
use buzz_core_pkg::{kind::KIND_USER_TRUSTED_ASSERTION, PresenceStatus};
|
||||
use serde_json::Value;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
commands::identity_archive::fetch_relay_self,
|
||||
events,
|
||||
managed_agents::persona_events::monotonic_created_at,
|
||||
models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse},
|
||||
@@ -16,24 +17,93 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
async fn query_profiles_with_assertions(
|
||||
state: &AppState,
|
||||
pubkeys: &[String],
|
||||
) -> Result<(Vec<nostr::Event>, Option<String>), String> {
|
||||
if pubkeys.is_empty() {
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
|
||||
let relay_self = fetch_relay_self(state).await.unwrap_or(None);
|
||||
let mut filters = vec![serde_json::json!({
|
||||
"kinds": [0],
|
||||
"authors": pubkeys,
|
||||
})];
|
||||
if let Some(author) = relay_self.as_ref() {
|
||||
filters.push(serde_json::json!({
|
||||
"kinds": [KIND_USER_TRUSTED_ASSERTION],
|
||||
"authors": [author],
|
||||
"#d": pubkeys,
|
||||
}));
|
||||
}
|
||||
Ok((query_relay(state, &filters).await?, relay_self))
|
||||
}
|
||||
|
||||
fn verified_identities(
|
||||
events: &[nostr::Event],
|
||||
relay_self: Option<&str>,
|
||||
) -> HashMap<String, String> {
|
||||
let Some(relay_self) = relay_self else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let mut verified = HashMap::<String, (u64, String)>::new();
|
||||
for event in events {
|
||||
if event.kind.as_u16() as u32 != KIND_USER_TRUSTED_ASSERTION
|
||||
|| !event.pubkey.to_hex().eq_ignore_ascii_case(relay_self)
|
||||
|| !event.verify_id()
|
||||
|| !event.verify_signature()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let tag_value = |name: &str| {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
let parts = tag.as_slice();
|
||||
(parts.len() == 2 && parts[0] == name).then(|| parts[1].as_str())
|
||||
})
|
||||
};
|
||||
if tag_value("verified") != Some("corporate") {
|
||||
continue;
|
||||
}
|
||||
let (Some(subject), Some(display_name)) = (tag_value("d"), tag_value("display_name"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if tag_value("p") != Some(subject)
|
||||
|| subject.len() != 64
|
||||
|| !subject.chars().all(|value| value.is_ascii_hexdigit())
|
||||
|| display_name.trim().is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let entry = verified
|
||||
.entry(subject.to_ascii_lowercase())
|
||||
.or_insert_with(|| (0, String::new()));
|
||||
if event.created_at.as_secs() >= entry.0 {
|
||||
*entry = (event.created_at.as_secs(), display_name.to_string());
|
||||
}
|
||||
}
|
||||
verified
|
||||
.into_iter()
|
||||
.map(|(pubkey, (_, display_name))| (pubkey, display_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile(state: State<'_, AppState>) -> Result<ProfileInfo, String> {
|
||||
let my_pubkey = current_pubkey_hex(&state)?;
|
||||
let events = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [0],
|
||||
"authors": [my_pubkey],
|
||||
"limit": 1
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
let (events, relay_self) =
|
||||
query_profiles_with_assertions(&state, std::slice::from_ref(&my_pubkey)).await?;
|
||||
|
||||
Ok(events
|
||||
.first()
|
||||
let mut profile = events
|
||||
.iter()
|
||||
.find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == my_pubkey)
|
||||
.map(nostr_convert::profile_info_from_event)
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state))))
|
||||
.unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state)));
|
||||
profile.verified_name = verified_identities(&events, relay_self.as_deref())
|
||||
.remove(&profile.pubkey);
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -187,21 +257,18 @@ pub async fn get_user_profile(
|
||||
None => current_pubkey_hex(&state)?,
|
||||
};
|
||||
|
||||
let events = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [0],
|
||||
"authors": [target.clone()],
|
||||
"limit": 1
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
let (events, relay_self) =
|
||||
query_profiles_with_assertions(&state, std::slice::from_ref(&target)).await?;
|
||||
|
||||
Ok(events
|
||||
.first()
|
||||
let mut profile = events
|
||||
.iter()
|
||||
.find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == target)
|
||||
.map(nostr_convert::profile_info_from_event)
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| empty_profile_info(&target)))
|
||||
.unwrap_or_else(|| empty_profile_info(&target));
|
||||
profile.verified_name = verified_identities(&events, relay_self.as_deref())
|
||||
.remove(&profile.pubkey);
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -215,16 +282,14 @@ pub async fn get_users_batch(
|
||||
missing: Vec::new(),
|
||||
});
|
||||
}
|
||||
let events = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [0],
|
||||
"authors": pubkeys,
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
let (events, relay_self) = query_profiles_with_assertions(&state, &pubkeys).await?;
|
||||
|
||||
Ok(nostr_convert::users_batch_from_events(&events, &pubkeys))
|
||||
let mut response = nostr_convert::users_batch_from_events(&events, &pubkeys);
|
||||
let verified = verified_identities(&events, relay_self.as_deref());
|
||||
for (pubkey, profile) in &mut response.profiles {
|
||||
profile.verified_name = verified.get(pubkey).cloned();
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -406,6 +471,7 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo {
|
||||
ProfileInfo {
|
||||
pubkey: pubkey.to_string(),
|
||||
display_name: None,
|
||||
verified_name: None,
|
||||
avatar_url: None,
|
||||
about: None,
|
||||
nip05_handle: None,
|
||||
@@ -418,6 +484,28 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verified_identity_requires_relay_signed_nip85_assertion() {
|
||||
let relay = nostr::Keys::generate();
|
||||
let subject = nostr::Keys::generate().public_key().to_hex();
|
||||
let event = nostr::EventBuilder::new(
|
||||
nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16),
|
||||
"",
|
||||
)
|
||||
.tags([
|
||||
nostr::Tag::parse(["d", subject.as_str()]).unwrap(),
|
||||
nostr::Tag::parse(["p", subject.as_str()]).unwrap(),
|
||||
nostr::Tag::parse(["verified", "corporate"]).unwrap(),
|
||||
nostr::Tag::parse(["display_name", "Franco Sola"]).unwrap(),
|
||||
nostr::Tag::parse(["issuer", "cf-doorman-production"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&relay)
|
||||
.unwrap();
|
||||
|
||||
let verified = verified_identities(&[event], Some(&relay.public_key().to_hex()));
|
||||
assert_eq!(verified.get(&subject).map(String::as_str), Some("Franco Sola"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() {
|
||||
let state = crate::app_state::build_app_state();
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct IdentityInfo {
|
||||
pub struct ProfileInfo {
|
||||
pub pubkey: String,
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub verified_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub about: Option<String>,
|
||||
pub nip05_handle: Option<String>,
|
||||
@@ -42,6 +44,8 @@ pub struct ProfileInfo {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UserProfileSummaryInfo {
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub verified_name: Option<String>,
|
||||
/// Kind-0 `name` field, carried separately from `display_name` so clients
|
||||
/// can match @mention text against either alias (agents and the CLI
|
||||
/// resolve mentions server-side against `display_name` *or* `name`).
|
||||
@@ -64,6 +68,8 @@ pub struct UsersBatchResponse {
|
||||
pub struct UserSearchResultInfo {
|
||||
pub pubkey: String,
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub verified_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub nip05_handle: Option<String>,
|
||||
pub owner_pubkey: Option<String>,
|
||||
|
||||
@@ -300,6 +300,7 @@ pub fn profile_info_from_event(event: &Event) -> Result<ProfileInfo, String> {
|
||||
Ok(ProfileInfo {
|
||||
pubkey: event.pubkey.to_hex(),
|
||||
display_name,
|
||||
verified_name: None,
|
||||
avatar_url,
|
||||
about,
|
||||
nip05_handle,
|
||||
@@ -319,6 +320,9 @@ pub fn users_batch_from_events(
|
||||
// Keep only the most recent kind:0 per pubkey.
|
||||
let mut latest: HashMap<String, &Event> = HashMap::new();
|
||||
for ev in events {
|
||||
if ev.kind.as_u16() != 0 {
|
||||
continue;
|
||||
}
|
||||
let pk = ev.pubkey.to_hex();
|
||||
let take = match latest.get(&pk) {
|
||||
None => true,
|
||||
@@ -339,6 +343,7 @@ pub fn users_batch_from_events(
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| v.get("name").and_then(Value::as_str))
|
||||
.map(str::to_string),
|
||||
verified_name: None,
|
||||
name: v.get("name").and_then(Value::as_str).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),
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo {
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| v.get("name").and_then(Value::as_str))
|
||||
.map(str::to_string),
|
||||
verified_name: None,
|
||||
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: owner_pubkey.is_some(),
|
||||
|
||||
@@ -11,7 +11,10 @@ import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
|
||||
import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
resolveUserVerification,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider";
|
||||
import {
|
||||
@@ -31,6 +34,7 @@ import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAu
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { VerifiedBadge } from "@/shared/ui/VerifiedBadge";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
|
||||
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
|
||||
@@ -460,6 +464,9 @@ export const MessageRow = React.memo(
|
||||
) : (
|
||||
<MessageAuthorText as="h3">{message.author}</MessageAuthorText>
|
||||
);
|
||||
const verifiedName = message.pubkey
|
||||
? resolveUserVerification({ pubkey: message.pubkey, profiles })
|
||||
: null;
|
||||
const agentOwnerNode = message.isAgent ? (
|
||||
<MessageAgentOwner
|
||||
ownerLabel={message.ownerLabel}
|
||||
@@ -552,6 +559,9 @@ export const MessageRow = React.memo(
|
||||
) : (
|
||||
authorNode
|
||||
)}
|
||||
{verifiedName ? (
|
||||
<VerifiedBadge verifiedName={verifiedName} />
|
||||
) : null}
|
||||
{agentOwnerNode}
|
||||
{inlineMetadataNode}
|
||||
{message.personaDisplayName &&
|
||||
|
||||
@@ -37,6 +37,7 @@ export function profileLookupsEqual(
|
||||
if (
|
||||
next === undefined ||
|
||||
prev.displayName !== next.displayName ||
|
||||
prev.verifiedName !== next.verifiedName ||
|
||||
prev.name !== next.name ||
|
||||
prev.avatarUrl !== next.avatarUrl ||
|
||||
prev.nip05Handle !== next.nip05Handle ||
|
||||
@@ -64,7 +65,10 @@ function getResolvedProfile(
|
||||
export function mergeCurrentProfileIntoLookup(
|
||||
profiles: UserProfileLookup | undefined,
|
||||
currentProfile:
|
||||
| Pick<Profile, "pubkey" | "displayName" | "avatarUrl" | "nip05Handle">
|
||||
| Pick<
|
||||
Profile,
|
||||
"pubkey" | "displayName" | "verifiedName" | "avatarUrl" | "nip05Handle"
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
@@ -76,6 +80,7 @@ export function mergeCurrentProfileIntoLookup(
|
||||
...(profiles ?? {}),
|
||||
[normalizePubkey(currentProfile.pubkey)]: {
|
||||
displayName: currentProfile.displayName,
|
||||
verifiedName: currentProfile.verifiedName ?? null,
|
||||
// `Profile` does not carry the kind-0 `name`; keep whatever the batch
|
||||
// lookup already resolved so mention aliases survive the merge.
|
||||
name: profiles?.[normalizePubkey(currentProfile.pubkey)]?.name ?? null,
|
||||
@@ -113,7 +118,11 @@ export function resolveUserLabel(input: {
|
||||
}
|
||||
|
||||
const profile = getResolvedProfile(pubkey, profiles);
|
||||
const verifiedName = profile?.verifiedName?.trim();
|
||||
const displayName = profile?.displayName?.trim();
|
||||
if (verifiedName) {
|
||||
return verifiedName;
|
||||
}
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
@@ -131,6 +140,16 @@ export function resolveUserLabel(input: {
|
||||
return truncatePubkey(pubkey);
|
||||
}
|
||||
|
||||
export function resolveUserVerification(input: {
|
||||
pubkey: string;
|
||||
profiles?: UserProfileLookup;
|
||||
}): string | null {
|
||||
return (
|
||||
getResolvedProfile(input.pubkey, input.profiles)?.verifiedName?.trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current user owns the agent that authored a message.
|
||||
* Mirrors the relay's `is_agent_owner` gate: ownership is determined by the
|
||||
|
||||
@@ -17,11 +17,13 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
|
||||
import type { PresenceStatus } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import { VerifiedBadge } from "@/shared/ui/VerifiedBadge";
|
||||
|
||||
interface ProfilePopoverProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
displayName: string;
|
||||
verifiedName?: string | null;
|
||||
avatarUrl: string | null;
|
||||
avatarDataUrl?: string | null;
|
||||
currentStatus: PresenceStatus;
|
||||
@@ -53,6 +55,7 @@ export function ProfilePopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
displayName,
|
||||
verifiedName,
|
||||
avatarUrl,
|
||||
avatarDataUrl,
|
||||
currentStatus,
|
||||
@@ -140,9 +143,14 @@ export function ProfilePopover({
|
||||
/>
|
||||
</MaskedAvatarBadgeFrame>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold leading-tight text-popover-foreground">
|
||||
{displayName}
|
||||
</p>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className="truncate text-sm font-semibold leading-tight text-popover-foreground">
|
||||
{displayName}
|
||||
</p>
|
||||
{verifiedName ? (
|
||||
<VerifiedBadge verifiedName={verifiedName} />
|
||||
) : null}
|
||||
</div>
|
||||
{/* ── Presence chip (opens status chooser) ─────────── */}
|
||||
<Popover
|
||||
onOpenChange={setPresenceMenuOpen}
|
||||
|
||||
@@ -51,6 +51,7 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
|
||||
import { useNow } from "@/shared/lib/useNow";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { VerifiedBadge } from "@/shared/ui/VerifiedBadge";
|
||||
|
||||
type UserProfilePopoverProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -234,7 +235,14 @@ export function UserProfilePopover({
|
||||
relayAgentsQuery.isPending ||
|
||||
managedAgentsQuery.isPending ||
|
||||
usersBatchQuery.isPending);
|
||||
const displayName = profile?.displayName ?? truncatePubkey(pubkey);
|
||||
const displayName =
|
||||
profile?.verifiedName ?? profile?.displayName ?? truncatePubkey(pubkey);
|
||||
const profileAlias =
|
||||
profile?.verifiedName &&
|
||||
profile.displayName &&
|
||||
profile.verifiedName !== profile.displayName
|
||||
? profile.displayName
|
||||
: null;
|
||||
// Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
|
||||
// runs elsewhere holds no local seckey, so key custody (`isOwner`) alone
|
||||
// wrongly hides the affordance from them — and gating on bot-ness alone shows
|
||||
@@ -520,6 +528,9 @@ export function UserProfilePopover({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HoverPubkeyName displayName={displayName} pubkey={pubkey} />
|
||||
{profile?.verifiedName ? (
|
||||
<VerifiedBadge verifiedName={profile.verifiedName} />
|
||||
) : null}
|
||||
{isBotProfile && botIdenticonValue ? (
|
||||
<BotIdenticon
|
||||
value={botIdenticonValue}
|
||||
@@ -528,6 +539,11 @@ export function UserProfilePopover({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{profileAlias ? (
|
||||
<p className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
|
||||
{profileAlias}
|
||||
</p>
|
||||
) : null}
|
||||
{isBotProfile && ownerLabel ? (
|
||||
<p
|
||||
className="mt-0.5 truncate text-xs leading-4 text-muted-foreground"
|
||||
|
||||
@@ -478,7 +478,7 @@ export function AppSidebar({
|
||||
directMessages,
|
||||
enabled: shouldLoadDmMetadata,
|
||||
fallbackDisplayName,
|
||||
profileDisplayName: profile?.displayName,
|
||||
profileDisplayName: profile?.verifiedName ?? profile?.displayName,
|
||||
});
|
||||
const sortedDirectMessages = React.useMemo(
|
||||
() =>
|
||||
@@ -498,6 +498,7 @@ export function AppSidebar({
|
||||
streamChannels,
|
||||
});
|
||||
const resolvedDisplayName =
|
||||
profile?.verifiedName?.trim() ||
|
||||
profile?.displayName?.trim() ||
|
||||
fallbackDisplayName?.trim() ||
|
||||
"Current identity";
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Community } from "@/features/communities/types";
|
||||
import { CommunitySwitcher } from "@/features/communities/ui/CommunitySwitcher";
|
||||
import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { VerifiedBadge } from "@/shared/ui/VerifiedBadge";
|
||||
|
||||
type SidebarProfileCardProps = {
|
||||
activeCommunity: Community | null;
|
||||
@@ -147,6 +148,7 @@ export function SidebarProfileCard({
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
currentStatus={selfPresenceStatus}
|
||||
displayName={resolvedDisplayName}
|
||||
verifiedName={profile?.verifiedName}
|
||||
isStatusPending={isPresencePending}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onOpenSettings={onOpenSettings}
|
||||
@@ -177,12 +179,17 @@ export function SidebarProfileCard({
|
||||
data-testid="open-settings"
|
||||
type="button"
|
||||
>
|
||||
<p
|
||||
className="truncate text-sm font-semibold leading-tight text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</p>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className="truncate text-sm font-semibold leading-tight text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</span>
|
||||
{profile?.verifiedName ? (
|
||||
<VerifiedBadge verifiedName={profile.verifiedName} />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</ProfilePopover>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
type RawProfile = {
|
||||
pubkey: string;
|
||||
display_name: string | null;
|
||||
verified_name?: string | null;
|
||||
avatar_url: string | null;
|
||||
about: string | null;
|
||||
nip05_handle: string | null;
|
||||
@@ -39,6 +40,7 @@ function fromRawProfile(profile: RawProfile): Profile {
|
||||
return {
|
||||
pubkey: profile.pubkey,
|
||||
displayName: profile.display_name,
|
||||
verifiedName: profile.verified_name ?? null,
|
||||
avatarUrl: profile.avatar_url,
|
||||
about: profile.about,
|
||||
nip05Handle: profile.nip05_handle,
|
||||
@@ -52,6 +54,7 @@ function fromRawUserProfileSummary(
|
||||
): UserProfileSummary {
|
||||
return {
|
||||
displayName: profile.display_name,
|
||||
verifiedName: profile.verified_name ?? null,
|
||||
name: profile.name ?? null,
|
||||
avatarUrl: profile.avatar_url,
|
||||
nip05Handle: profile.nip05_handle,
|
||||
@@ -64,6 +67,7 @@ function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult {
|
||||
return {
|
||||
pubkey: user.pubkey,
|
||||
displayName: user.display_name,
|
||||
verifiedName: user.verified_name ?? null,
|
||||
avatarUrl: user.avatar_url,
|
||||
nip05Handle: user.nip05_handle,
|
||||
ownerPubkey: user.owner_pubkey,
|
||||
|
||||
@@ -126,6 +126,8 @@ export type Identity = {
|
||||
export type Profile = {
|
||||
pubkey: string;
|
||||
displayName: string | null;
|
||||
/** Relay-authoritative corporate display identity bound to this pubkey. */
|
||||
verifiedName?: string | null;
|
||||
avatarUrl: string | null;
|
||||
about: string | null;
|
||||
nip05Handle: string | null;
|
||||
@@ -139,6 +141,8 @@ export type Profile = {
|
||||
|
||||
export type UserProfileSummary = {
|
||||
displayName: string | null;
|
||||
/** Relay-authoritative corporate display identity bound to this pubkey. */
|
||||
verifiedName?: string | null;
|
||||
/** Kind-0 `name` field, kept separate from `displayName` so @mention text
|
||||
* can be matched against either alias (agents/CLI resolve mentions against
|
||||
* `display_name` *or* `name` at send time). */
|
||||
@@ -157,6 +161,7 @@ export type UsersBatchResponse = {
|
||||
export type UserSearchResult = {
|
||||
pubkey: string;
|
||||
displayName: string | null;
|
||||
verifiedName?: string | null;
|
||||
avatarUrl: string | null;
|
||||
nip05Handle: string | null;
|
||||
ownerPubkey: string | null;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BadgeCheck } from "lucide-react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
export function VerifiedBadge({ verifiedName }: { verifiedName: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={`Verified corporate identity: ${verifiedName}`}
|
||||
className="inline-flex shrink-0 items-center text-blue-500"
|
||||
data-testid="verified-corporate-identity"
|
||||
>
|
||||
<BadgeCheck aria-hidden="true" className="h-4 w-4" fill="currentColor" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">Verified as {verifiedName}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user