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

3
assets/icons/group.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.75 19.25h2.596c1.163 0 2.106-1.001 1.788-2.12-.733-2.573-2.465-4.38-5.134-4.38-.446 0-.866.05-1.26.147M11.25 7a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Zm8.5.5a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0ZM2.08 18.126c.78-3.14 2.78-5.376 5.92-5.376s5.14 2.237 5.918 5.376c.28 1.128-.658 2.124-1.82 2.124H3.901c-1.162 0-2.1-.996-1.82-2.124Z"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

View File

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

View File

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

View File

@@ -11,7 +11,9 @@ use registry::room::RoomKind;
use registry::Registry; use registry::Registry;
use settings::AppSettings; use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::actions::{CopyPublicKey, OpenPublicKey};
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::context_menu::ContextMenuExt;
use ui::modal::ModalButtonProps; use ui::modal::ModalButtonProps;
use ui::skeleton::Skeleton; use ui::skeleton::Skeleton;
use ui::{h_flex, ContextModal, StyledExt}; use ui::{h_flex, ContextModal, StyledExt};
@@ -166,6 +168,10 @@ impl RenderOnce for RoomListItem {
), ),
) )
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .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| { .on_click(move |event, window, cx| {
handler(event, window, cx); handler(event, window, cx);

View File

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

View File

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

View File

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

View File

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

View File

@@ -272,9 +272,11 @@ profile:
unknown: unknown:
en: "Unknown contact" en: "Unknown contact"
njump: njump:
en: "Open in njump.me" en: "View on njump.me"
no_bio: no_bio:
en: "No bio." en: "No bio."
copy:
en: "Copy Public Key"
preferences: preferences:
account_header: account_header: