From 0de1b20951d17165309403d5ef07a90f4a7e46b6 Mon Sep 17 00:00:00 2001
From: reya <123083837+reyamir@users.noreply.github.com>
Date: Sat, 27 Sep 2025 15:15:00 +0700
Subject: [PATCH] feat: add context menu for quick profile viewing (#170)
* add profile context menu
* add context menu for avatar
---
assets/icons/group.svg | 3 ++
crates/coop/src/chatspace.rs | 17 +++++++----
crates/coop/src/views/chat/mod.rs | 20 ++++++++++---
crates/coop/src/views/sidebar/list_item.rs | 6 ++++
crates/coop/src/views/user_profile.rs | 34 ++++++++++------------
crates/ui/src/actions.rs | 11 +++++--
crates/ui/src/icon.rs | 2 ++
crates/ui/src/text.rs | 4 +--
locales/app.yml | 4 ++-
9 files changed, 67 insertions(+), 34 deletions(-)
create mode 100644 assets/icons/group.svg
diff --git a/assets/icons/group.svg b/assets/icons/group.svg
new file mode 100644
index 0000000..7f94e3c
--- /dev/null
+++ b/assets/icons/group.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs
index 24fa1b4..dd908d4 100644
--- a/crates/coop/src/chatspace.rs
+++ b/crates/coop/src/chatspace.rs
@@ -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) {
+ fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context) {
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) {
+ 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()
diff --git a/crates/coop/src/views/chat/mod.rs b/crates/coop/src/views/chat/mod.rs
index 5883fad..ed7846f 100644
--- a/crates/coop/src/views/chat/mod.rs
+++ b/crates/coop/src/views/chat/mod.rs
@@ -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)
diff --git a/crates/coop/src/views/sidebar/list_item.rs b/crates/coop/src/views/sidebar/list_item.rs
index 5a607ac..33363b1 100644
--- a/crates/coop/src/views/sidebar/list_item.rs
+++ b/crates/coop/src/views/sidebar/list_item.rs
@@ -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);
diff --git a/crates/coop/src/views/user_profile.rs b/crates/coop/src/views/user_profile.rs
index 81d61e7..c144762 100644
--- a/crates/coop/src/views/user_profile.rs
+++ b/crates/coop/src/views/user_profile.rs
@@ -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 {
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 {
+ pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context) -> 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 = cx.background_spawn(async move {
+ let check_follow: Task> = 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)
diff --git a/crates/ui/src/actions.rs b/crates/ui/src/actions.rs
index 3de2809..0abc6c8 100644
--- a/crates/ui/src/actions.rs
+++ b/crates/ui/src/actions.rs
@@ -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)]
diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs
index 143fbbb..940e190 100644
--- a/crates/ui/src/icon.rs
+++ b/crates/ui/src/icon.rs
@@ -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",
diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs
index 57e0190..a36c1d9 100644
--- a/crates/ui/src/text.rs
+++ b/crates/ui/src/text.rs
@@ -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 = 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}"));
diff --git a/locales/app.yml b/locales/app.yml
index 297ed2f..f21b0db 100644
--- a/locales/app.yml
+++ b/locales/app.yml
@@ -272,9 +272,11 @@ profile:
unknown:
en: "Unknown contact"
njump:
- en: "Open in njump.me"
+ en: "View on njump.me"
no_bio:
en: "No bio."
+ copy:
+ en: "Copy Public Key"
preferences:
account_header: