chore: improve render message (#84)

* .

* refactor upload button

* refactor

* dispatch action on mention clicked

* add profile modal

* .

* .

* .

* improve rich_text

* improve handle url

* make registry simpler

* refactor

* .

* clean up
This commit is contained in:
reya
2025-07-16 14:37:26 +07:00
committed by GitHub
parent 9f02942d87
commit 8195eedaf6
21 changed files with 887 additions and 468 deletions

View File

@@ -1,6 +1,12 @@
use gpui::{actions, Action};
use nostr_sdk::prelude::PublicKey;
use serde::Deserialize;
/// Define a open profile action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = profile, no_json)]
pub struct OpenProfile(pub PublicKey);
/// Define a custom confirm action
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
#[action(namespace = list, no_json)]

View File

@@ -572,8 +572,12 @@ impl DockArea {
}
}
DockPlacement::Center => {
let focus_handle = panel.focus_handle(cx);
// Add panel
self.items
.add_panel(panel, &cx.entity().downgrade(), window, cx);
// Focus to the newly added panel
window.focus(&focus_handle);
}
}
}

View File

@@ -8,7 +8,7 @@ pub use window_border::{window_border, WindowBorder};
pub use crate::Disableable;
pub(crate) mod actions;
pub mod actions;
pub mod animation;
pub mod avatar;
pub mod button;

View File

@@ -1,40 +1,59 @@
use std::collections::HashMap;
use std::ops::Range;
use std::sync::Arc;
use common::display::DisplayProfile;
use gpui::{
AnyElement, AnyView, App, ElementId, FontWeight, HighlightStyle, InteractiveText, IntoElement,
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
SharedString, StyledText, UnderlineStyle, Window,
};
use linkify::{LinkFinder, LinkKind};
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use regex::Regex;
use registry::Registry;
use theme::ActiveTheme;
use crate::actions::OpenProfile;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap()
});
static NOSTR_URI_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
static BECH32_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+\b").unwrap());
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Highlight(HighlightStyle),
Mention,
Link(HighlightStyle),
Nostr,
}
impl Highlight {
fn link() -> Self {
Self::Link(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
})
}
fn nostr() -> Self {
Self::Nostr
}
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Highlight(style)
Self::Link(style)
}
}
type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[derive(Clone, Default)]
#[derive(Default)]
pub struct RichText {
pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>,
@@ -45,19 +64,19 @@ pub struct RichText {
}
impl RichText {
pub fn new(content: String, profiles: &[Option<Profile>]) -> Self {
pub fn new(content: &str, cx: &App) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
&content,
profiles,
content,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
cx,
);
text.truncate(text.trim_end().len());
@@ -72,10 +91,10 @@ impl RichText {
}
}
pub fn set_tooltip_builder_for_custom_ranges(
&mut self,
f: impl Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
) {
pub fn set_tooltip_builder_for_custom_ranges<F>(&mut self, f: F)
where
F: Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
{
self.custom_ranges_tooltip_fn = Some(Arc::new(f));
}
@@ -90,7 +109,7 @@ impl RichText {
(
range.clone(),
match highlight {
Highlight::Highlight(highlight) => {
Highlight::Link(highlight) => {
// Check if this is a link highlight by seeing if it has an underline
if highlight.underline.is_some() {
// It's a link, so apply the link color
@@ -101,9 +120,8 @@ impl RichText {
*highlight
}
}
Highlight::Mention => HighlightStyle {
Highlight::Nostr => HighlightStyle {
color: Some(link_color),
font_weight: Some(FontWeight::MEDIUM),
..Default::default()
},
},
@@ -113,15 +131,24 @@ impl RichText {
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, _, cx| {
let url = &link_urls[ix];
if url.starts_with("http") {
cx.open_url(url);
}
// Handle mention URLs
else if url.starts_with("mention:") {
// Handle mention clicks
// For example: cx.emit_custom_event(MentionClicked(url.strip_prefix("mention:").unwrap().to_string()));
move |ix, window, cx| {
let token = link_urls[ix].as_str();
if token.starts_with("nostr:") {
let clean_url = token.replace("nostr:", "");
let Ok(public_key) = PublicKey::parse(&clean_url) else {
log::error!("Failed to parse public key from: {clean_url}");
return;
};
window.dispatch_action(Box::new(OpenProfile(public_key)), cx);
} else if is_url(token) {
if !token.starts_with("http") {
cx.open_url(&format!("https://{token}"));
} else {
cx.open_url(token);
}
} else {
log::warn!("Unrecognized token {token}")
}
}
})
@@ -154,29 +181,20 @@ impl RichText {
}
}
pub fn render_plain_text_mut(
fn render_plain_text_mut(
content: &str,
profiles: &[Option<Profile>],
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
// Copy the content directly
text.push_str(content);
// Create a profile lookup using PublicKey directly
let profile_lookup: HashMap<PublicKey, Profile> = profiles
.iter()
.filter_map(|profile| {
profile
.as_ref()
.map(|profile| (profile.public_key(), profile.clone()))
})
.collect();
// Process regular URLs using linkify
// Initialize the link finder
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
finder.kinds(&[LinkKind::Url]);
// Collect all URLs
@@ -191,7 +209,7 @@ pub fn render_plain_text_mut(
url_matches.push((range, url));
}
// Process nostr entities with nostr: prefix
// Collect all nostr entities with nostr: prefix
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
@@ -209,141 +227,158 @@ pub fn render_plain_text_mut(
}
}
// Process raw bech32 entities (without nostr: prefix)
let mut bech32_matches: Vec<(Range<usize>, String)> = Vec::new();
for bech32_match in BECH32_REGEX.find_iter(content) {
let start = bech32_match.start();
let end = bech32_match.end();
let range = start..end;
let bech32_entity = bech32_match.as_str().to_string();
// Check if this entity overlaps with any already processed matches
let overlaps_with_url = url_matches
.iter()
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end);
let overlaps_with_nostr = nostr_matches
.iter()
.any(|(nostr_range, _)| nostr_range.start < range.end && range.start < nostr_range.end);
if !overlaps_with_url && !overlaps_with_nostr {
bech32_matches.push((range, bech32_entity));
}
}
// Combine all matches for processing from end to start
let mut all_matches = Vec::new();
all_matches.extend(url_matches);
all_matches.extend(nostr_matches);
all_matches.extend(bech32_matches);
// Sort by position (end to start) to avoid changing positions when replacing text
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
// Process all matches
for (range, entity) in all_matches {
if entity.starts_with("http") {
// Regular URL
highlights.push((
range.clone(),
Highlight::Highlight(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
));
// Handle URL token
if is_url(&entity) {
// Add underline highlight
highlights.push((range.clone(), Highlight::link()));
// Make it clickable
link_ranges.push(range);
link_urls.push(entity);
} else {
let entity_without_prefix = if entity.starts_with("nostr:") {
entity.strip_prefix("nostr:").unwrap_or(&entity)
} else {
&entity
};
// Try to find a matching profile if this is npub or nprofile
let profile_match = if entity_without_prefix.starts_with("npub") {
PublicKey::from_bech32(entity_without_prefix)
.ok()
.and_then(|pubkey| profile_lookup.get(&pubkey).cloned())
} else if entity_without_prefix.starts_with("nprofile") {
Nip19Profile::from_bech32(entity_without_prefix)
.ok()
.and_then(|profile| profile_lookup.get(&profile.public_key).cloned())
} else {
None
};
continue;
};
if let Some(profile) = profile_match {
// Profile found - create a mention
let display_name = format!("@{}", profile.display_name());
// Replace mention with profile name
text.replace_range(range.clone(), &display_name);
// Adjust ranges
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add highlight for the profile name
highlights.push((new_range.clone(), Highlight::Mention));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(format!("mention:{entity_without_prefix}"));
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
if let Ok(nip21) = Nip21::parse(&entity) {
match nip21 {
Nip21::Pubkey(public_key) => {
render_pubkey(
public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
} else {
// No profile match or not a profile entity - create njump.me link
let njump_url = format!("https://njump.me/{entity_without_prefix}");
// Create a shortened display format for the URL
let shortened_entity = format_shortened_entity(entity_without_prefix);
let display_text = format!("https://njump.me/{shortened_entity}");
// Replace the original entity with the shortened display version
text.replace_range(range.clone(), &display_text);
// Adjust the ranges
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add underline highlight
highlights.push((
new_range.clone(),
Highlight::Highlight(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(njump_url);
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
Nip21::Profile(nip19_profile) => {
render_pubkey(
nip19_profile.public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::EventId(event_id) => {
render_bech32(
event_id.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Event(nip19_event) => {
render_bech32(
nip19_event.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Coordinate(nip19_coordinate) => {
render_bech32(
nip19_coordinate.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
}
}
}
fn render_pubkey(
public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let registry = Registry::read_global(cx);
let profile = registry.get_person(&public_key, cx);
let display_name = format!("@{}", profile.display_name());
// Replace token with display name
text.replace_range(range.clone(), &display_name);
// Adjust ranges
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add highlight for the profile name
highlights.push((new_range.clone(), Highlight::nostr()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
let njump_url = format!("https://njump.me/{bech32}");
// Create a shortened display format for the URL
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
// Replace the original entity with the shortened display version
text.replace_range(range.clone(), &display_text);
// Adjust the ranges
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add underline highlight
highlights.push((new_range.clone(), Highlight::link()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(njump_url);
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
}
/// Check if a string is a URL
fn is_url(s: &str) -> bool {
URL_REGEX.is_match(s)
}
/// Format a bech32 entity with ellipsis and last 4 characters