feat: add context menu for quick profile viewing (#170)

* add profile context menu

* add context menu for avatar
This commit is contained in:
reya
2025-09-27 15:15:00 +07:00
committed by GitHub
parent 338a947b57
commit 0de1b20951
9 changed files with 67 additions and 34 deletions

View File

@@ -16,8 +16,8 @@ use global::constants::{
use global::{app_state, nostr_client, AuthRequest, Notice, SignalKind, UnwrappingStatus};
use gpui::prelude::FluentBuilder;
use gpui::{
deferred, div, px, rems, App, AppContext, AsyncWindowContext, Axis, Context, Entity,
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
deferred, div, px, rems, App, AppContext, AsyncWindowContext, Axis, ClipboardItem, Context,
Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window,
};
use i18n::{shared_t, t};
@@ -30,7 +30,7 @@ use signer_proxy::{BrowserSignerProxy, BrowserSignerProxyOptions};
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, Theme, ThemeMode};
use title_bar::TitleBar;
use ui::actions::OpenProfile;
use ui::actions::{CopyPublicKey, OpenPublicKey};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement;
@@ -1177,7 +1177,7 @@ impl ChatSpace {
.detach();
}
fn on_open_profile(&mut self, ev: &OpenProfile, window: &mut Window, cx: &mut Context<Self>) {
fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context<Self>) {
let public_key = ev.0;
let profile = user_profile::init(public_key, window, cx);
@@ -1195,6 +1195,12 @@ impl ChatSpace {
});
}
fn on_copy_pubkey(&mut self, ev: &CopyPublicKey, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = ev.0.to_bech32();
cx.write_to_clipboard(ClipboardItem::new_string(bech32));
window.push_notification(t!("common.copied"), cx);
}
fn render_proxy_modal(&mut self, window: &mut Window, cx: &mut App) {
window.open_modal(cx, |this, _window, _cx| {
this.overlay_closable(false)
@@ -1496,7 +1502,8 @@ impl Render for ChatSpace {
.on_action(cx.listener(Self::on_settings))
.on_action(cx.listener(Self::on_dark_mode))
.on_action(cx.listener(Self::on_sign_out))
.on_action(cx.listener(Self::on_open_profile))
.on_action(cx.listener(Self::on_open_pubkey))
.on_action(cx.listener(Self::on_copy_pubkey))
.on_action(cx.listener(Self::on_reload_metadata))
.relative()
.size_full()

View File

@@ -24,8 +24,10 @@ use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::fs;
use theme::ActiveTheme;
use ui::actions::{CopyPublicKey, OpenPublicKey};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::context_menu::ContextMenuExt;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::emoji_picker::EmojiPicker;
use ui::input::{InputEvent, InputState, TextInput};
@@ -693,6 +695,7 @@ impl Chat {
let id = message.id;
let author = self.profile(&message.author, cx);
let public_key = author.public_key();
let replies = message.replies_to.as_slice();
let has_replies = !replies.is_empty();
@@ -715,7 +718,18 @@ impl Chat {
.flex()
.gap_3()
.when(!hide_avatar, |this| {
this.child(Avatar::new(author.avatar(proxy)).size(rems(2.)))
this.child(
div()
.id(SharedString::from(format!("{ix}-avatar")))
.child(Avatar::new(author.avatar(proxy)).size(rems(2.)))
.context_menu(move |this, _window, _cx| {
let view = Box::new(OpenPublicKey(public_key));
let copy = Box::new(CopyPublicKey(public_key));
this.menu(t!("profile.view"), view)
.menu(t!("profile.copy"), copy)
}),
)
})
.child(
v_flex()
@@ -1318,9 +1332,7 @@ impl Panel for Chat {
let label = this.display_name(cx);
let url = this.display_image(proxy, cx);
div()
.flex()
.items_center()
h_flex()
.gap_1p5()
.child(Avatar::new(url).size(rems(1.25)))
.child(label)

View File

@@ -11,7 +11,9 @@ use registry::room::RoomKind;
use registry::Registry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::actions::{CopyPublicKey, OpenPublicKey};
use ui::avatar::Avatar;
use ui::context_menu::ContextMenuExt;
use ui::modal::ModalButtonProps;
use ui::skeleton::Skeleton;
use ui::{h_flex, ContextModal, StyledExt};
@@ -166,6 +168,10 @@ impl RenderOnce for RoomListItem {
),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.context_menu(move |this, _window, _cx| {
this.menu(t!("profile.view"), Box::new(OpenPublicKey(public_key)))
.menu(t!("profile.copy"), Box::new(CopyPublicKey(public_key)))
})
.on_click(move |event, window, cx| {
handler(event, window, cx);

View File

@@ -17,7 +17,7 @@ use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, StyledExt};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
cx.new(|cx| UserProfile::new(public_key, window, cx))
@@ -32,27 +32,24 @@ pub struct UserProfile {
}
impl UserProfile {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let registry = Registry::read_global(cx);
let identity = registry.identity(cx).public_key();
let profile = registry.get_person(&public_key, cx);
let profile = registry.get_person(&target, cx);
let mut tasks = smallvec![];
let check_follow: Task<bool> = cx.background_spawn(async move {
let check_follow: Task<Result<bool, Error>> = cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::ContactList)
.author(identity)
.pubkey(public_key)
.limit(1);
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let contact_list = client.database().contacts_public_keys(public_key).await?;
client.database().count(filter).await.unwrap_or(0) >= 1
Ok(contact_list.contains(&target))
});
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
nip05_verify(target, &address).await.unwrap_or(false)
}))
} else {
None
@@ -61,7 +58,7 @@ impl UserProfile {
tasks.push(
// Load user profile data
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await;
let followed = check_follow.await.unwrap_or(false);
// Update the followed status
this.update(cx, |this, cx| {
@@ -133,6 +130,7 @@ impl Render for UserProfile {
v_flex()
.gap_4()
.text_sm()
.child(
v_flex()
.gap_3()
@@ -188,10 +186,8 @@ impl Render for UserProfile {
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.block()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Public Key:")),
)
@@ -201,12 +197,13 @@ impl Render for UserProfile {
.child(
div()
.p_2()
.h_9()
.h_7()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.truncate()
.text_ellipsis()
.line_clamp(1)
.line_height(relative(1.))
.child(shared_bech32),
)
.child(
@@ -218,8 +215,8 @@ impl Render for UserProfile {
IconName::Copy
}
})
.ghost()
.disabled(self.copied)
.cta()
.ghost_alt()
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_pubkey(window, cx);
})),
@@ -229,7 +226,6 @@ impl Render for UserProfile {
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)

View File

@@ -2,10 +2,15 @@ use gpui::{actions, Action};
use nostr_sdk::prelude::PublicKey;
use serde::Deserialize;
/// Define a open profile action
/// Define a open public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = profile, no_json)]
pub struct OpenProfile(pub PublicKey);
#[action(namespace = pubkey, no_json)]
pub struct OpenPublicKey(pub PublicKey);
/// Define a copy inline public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct CopyPublicKey(pub PublicKey);
/// Define a custom confirm action
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]

View File

@@ -45,6 +45,7 @@ pub enum IconName {
Plus,
PlusFill,
PlusCircleFill,
Group,
ResizeCorner,
Reply,
Report,
@@ -106,6 +107,7 @@ impl IconName {
Self::Plus => "icons/plus.svg",
Self::PlusFill => "icons/plus-fill.svg",
Self::PlusCircleFill => "icons/plus-circle-fill.svg",
Self::Group => "icons/group.svg",
Self::ResizeCorner => "icons/resize-corner.svg",
Self::Reply => "icons/reply.svg",
Self::Report => "icons/report.svg",

View File

@@ -13,7 +13,7 @@ use regex::Regex;
use registry::Registry;
use theme::ActiveTheme;
use crate::actions::OpenProfile;
use crate::actions::OpenPublicKey;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap()
@@ -140,7 +140,7 @@ impl RenderedText {
log::error!("Failed to parse public key from: {clean_url}");
return;
};
window.dispatch_action(Box::new(OpenProfile(public_key)), cx);
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
} else if is_url(token) {
if !token.starts_with("http") {
cx.open_url(&format!("https://{token}"));