diff --git a/Cargo.lock b/Cargo.lock index 3ebe66f..4bdadc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1018,10 +1018,12 @@ dependencies = [ "gpui", "gpui_tokio", "itertools 0.13.0", + "linkify", "log", "nostr-sdk", "once_cell", "person", + "pulldown-cmark", "regex", "serde", "serde_json", @@ -2443,6 +2445,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -3707,6 +3718,15 @@ dependencies = [ "rust-ini", ] +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4975,6 +4995,25 @@ dependencies = [ "cc", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.27" diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index 7d4147b..658b111 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -28,3 +28,5 @@ serde_json.workspace = true once_cell = "1.19.0" regex = "1" +linkify = "0.10.0" +pulldown-cmark = "0.13.1" diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 975edab..124045a 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -8,19 +8,19 @@ use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport}; use common::RenderedTimestamp; use gpui::prelude::FluentBuilder; use gpui::{ - deferred, div, img, list, px, red, relative, svg, white, AnyElement, App, AppContext, - ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement, - PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage, - Subscription, Task, WeakEntity, Window, + AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, + ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, + Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red, + relative, svg, white, }; use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; use settings::{AppSettings, SignerKind}; -use smallvec::{smallvec, SmallVec}; +use smallvec::{SmallVec, smallvec}; use smol::lock::RwLock; -use state::{upload, NostrRegistry}; +use state::{NostrRegistry, upload}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -31,8 +31,8 @@ use ui::menu::{ContextMenuExt, DropdownMenu}; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{ - h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, - WindowExtension, + Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension, + h_flex, v_flex, }; use crate::text::RenderedText; @@ -702,7 +702,7 @@ impl ChatPanel { let text = self .rendered_texts_by_id .entry(rendered.id) - .or_insert_with(|| RenderedText::new(&rendered.content, cx)) + .or_insert_with(|| RenderedText::new(&rendered.content, &[])) .element(ix.into(), window, cx); self.render_text_message(ix, rendered, text, cx) diff --git a/crates/chat_ui/src/text.rs b/crates/chat_ui/src/text.rs index 9632dfb..55b8bf6 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -1,29 +1,32 @@ use std::ops::Range; use std::sync::Arc; +use common::RangeExt; use gpui::{ - AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString, - StyledText, UnderlineStyle, Window, + AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText, + IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window, }; -use nostr_sdk::prelude::*; -use once_cell::sync::Lazy; -use person::PersonRegistry; -use regex::Regex; use theme::ActiveTheme; -use crate::actions::OpenPublicKey; - -static URL_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap() -}); - -static NOSTR_URI_REGEX: Lazy = - Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap()); - +#[allow(clippy::enum_variant_names)] +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub enum Highlight { - Link, - Nostr, + Code, + InlineCode(bool), + Highlight(HighlightStyle), + Mention, +} + +impl From for Highlight { + fn from(style: HighlightStyle) -> Self { + Self::Highlight(style) + } +} + +#[derive(Debug)] +pub struct Mention { + pub range: Range, } #[derive(Default)] @@ -35,7 +38,7 @@ pub struct RenderedText { } impl RenderedText { - pub fn new(content: &str, cx: &App) -> Self { + pub fn new(content: &str, mentions: &[Mention]) -> Self { let mut text = String::new(); let mut highlights = Vec::new(); let mut link_ranges = Vec::new(); @@ -43,11 +46,11 @@ impl RenderedText { render_plain_text_mut( content, + mentions, &mut text, &mut highlights, &mut link_ranges, &mut link_urls, - cx, ); text.truncate(text.trim_end().len()); @@ -61,7 +64,7 @@ impl RenderedText { } pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement { - let link_color = cx.theme().text_accent; + let code_background = cx.theme().elevated_surface_background; InteractiveText::new( id, @@ -71,15 +74,32 @@ impl RenderedText { ( range.clone(), match highlight { - Highlight::Link => HighlightStyle { - color: Some(link_color), - underline: Some(UnderlineStyle::default()), + Highlight::Code => HighlightStyle { + background_color: Some(code_background), ..Default::default() }, - Highlight::Nostr => HighlightStyle { - color: Some(link_color), + Highlight::InlineCode(link) => { + if *link { + HighlightStyle { + background_color: Some(code_background), + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + } + } else { + HighlightStyle { + background_color: Some(code_background), + ..Default::default() + } + } + } + Highlight::Mention => HighlightStyle { + font_weight: Some(FontWeight::BOLD), ..Default::default() }, + Highlight::Highlight(highlight) => *highlight, }, ) }), @@ -87,22 +107,10 @@ impl RenderedText { ) .on_click(self.link_ranges.clone(), { let link_urls = self.link_urls.clone(); - move |ix, window, cx| { - let token = link_urls[ix].as_str(); - - if let Some(clean_url) = token.strip_prefix("nostr:") { - if let Ok(public_key) = PublicKey::parse(clean_url) { - window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx); - } - } else if is_url(token) { - let url = if token.starts_with("http") { - token.to_string() - } else { - format!("https://{token}") - }; - cx.open_url(&url); - } else { - log::warn!("Unrecognized token {token}") + move |ix, _, cx| { + let url = &link_urls[ix]; + if url.starts_with("http") { + cx.open_url(url); } } }) @@ -111,138 +119,205 @@ impl RenderedText { } fn render_plain_text_mut( - content: &str, + block: &str, + mut mentions: &[Mention], text: &mut String, highlights: &mut Vec<(Range, Highlight)>, link_ranges: &mut Vec>, link_urls: &mut Vec, - cx: &App, ) { - // Copy the content directly - text.push_str(content); + use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; - // Collect all URLs - let mut url_matches: Vec<(Range, String)> = Vec::new(); + let mut bold_depth = 0; + let mut italic_depth = 0; + let mut strikethrough_depth = 0; + let mut link_url = None; + let mut list_stack = Vec::new(); - for link in URL_REGEX.find_iter(content) { - let range = link.start()..link.end(); - let url = link.as_str().to_string(); + let mut options = Options::all(); + options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST); - url_matches.push((range, url)); - } + for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() { + let prev_len = text.len(); - // Collect all nostr entities with nostr: prefix - let mut nostr_matches: Vec<(Range, String)> = Vec::new(); - - for nostr_match in NOSTR_URI_REGEX.find_iter(content) { - let range = nostr_match.start()..nostr_match.end(); - let nostr_uri = nostr_match.as_str().to_string(); - - // Check if this nostr URI overlaps with any already processed URL - if !url_matches - .iter() - .any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end) - { - nostr_matches.push((range, nostr_uri)); - } - } - - // 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); - - // 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 { - // Handle URL token - if is_url(&entity) { - highlights.push((range.clone(), Highlight::Link)); - link_ranges.push(range); - link_urls.push(entity); - continue; - }; - - 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, - ); + match event { + Event::Text(t) => { + while let Some(mention) = mentions.first() { + if !source_range.contains_inclusive(&mention.range) { + break; + } + mentions = &mentions[1..]; + let range = (prev_len + mention.range.start - source_range.start) + ..(prev_len + mention.range.end - source_range.start); + highlights.push((range.clone(), Highlight::Mention)); } - Nip21::Profile(nip19_profile) => { - render_pubkey( - nip19_profile.public_key, - text, - &range, - highlights, - link_ranges, - link_urls, - cx, - ); + + text.push_str(t.as_ref()); + + let mut style = HighlightStyle::default(); + + if bold_depth > 0 { + style.font_weight = Some(FontWeight::BOLD); } - Nip21::EventId(event_id) => { - render_bech32( - event_id.to_bech32().unwrap(), - text, - &range, - highlights, - link_ranges, - link_urls, - ); + + if italic_depth > 0 { + style.font_style = Some(FontStyle::Italic); } - Nip21::Event(nip19_event) => { - render_bech32( - nip19_event.to_bech32().unwrap(), - text, - &range, - highlights, - link_ranges, - link_urls, - ); + + if strikethrough_depth > 0 { + style.strikethrough = Some(StrikethroughStyle { + thickness: 1.0.into(), + ..Default::default() + }); } - Nip21::Coordinate(nip19_coordinate) => { - render_bech32( - nip19_coordinate.to_bech32().unwrap(), - text, - &range, - highlights, - link_ranges, - link_urls, - ); + + let last_run_len = if let Some(link_url) = link_url.clone() { + link_ranges.push(prev_len..text.len()); + link_urls.push(link_url); + style.underline = Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }); + prev_len + } else { + // Manually scan for links + let mut finder = linkify::LinkFinder::new(); + finder.kinds(&[linkify::LinkKind::Url]); + let mut last_link_len = prev_len; + for link in finder.links(&t) { + let start = link.start(); + let end = link.end(); + let range = (prev_len + start)..(prev_len + end); + link_ranges.push(range.clone()); + link_urls.push(link.as_str().to_string()); + + // If there is a style before we match a link, we have to add this to the highlighted ranges + if style != HighlightStyle::default() && last_link_len < link.start() { + highlights + .push((last_link_len..link.start(), Highlight::Highlight(style))); + } + + highlights.push(( + range, + Highlight::Highlight(HighlightStyle { + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..style + }), + )); + + last_link_len = end; + } + last_link_len + }; + + if style != HighlightStyle::default() && last_run_len < text.len() { + let mut new_highlight = true; + if let Some((last_range, last_style)) = highlights.last_mut() { + if last_range.end == last_run_len + && last_style == &Highlight::Highlight(style) + { + last_range.end = text.len(); + new_highlight = false; + } + } + if new_highlight { + highlights.push((last_run_len..text.len(), Highlight::Highlight(style))); + } } } + Event::Code(t) => { + text.push_str(t.as_ref()); + let is_link = link_url.is_some(); + + if let Some(link_url) = link_url.clone() { + link_ranges.push(prev_len..text.len()); + link_urls.push(link_url); + } + + highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link))) + } + Event::Start(tag) => match tag { + Tag::Paragraph => new_paragraph(text, &mut list_stack), + Tag::Heading { .. } => { + new_paragraph(text, &mut list_stack); + bold_depth += 1; + } + Tag::CodeBlock(_kind) => { + new_paragraph(text, &mut list_stack); + } + Tag::Emphasis => italic_depth += 1, + Tag::Strong => bold_depth += 1, + Tag::Strikethrough => strikethrough_depth += 1, + Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()), + Tag::List(number) => { + list_stack.push((number, false)); + } + Tag::Item => { + let len = list_stack.len(); + if let Some((list_number, has_content)) = list_stack.last_mut() { + *has_content = false; + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + for _ in 0..len - 1 { + text.push_str(" "); + } + if let Some(number) = list_number { + text.push_str(&format!("{}. ", number)); + *number += 1; + *has_content = false; + } else { + text.push_str("- "); + } + } + } + _ => {} + }, + Event::End(tag) => match tag { + TagEnd::Heading(_) => bold_depth -= 1, + TagEnd::Emphasis => italic_depth -= 1, + TagEnd::Strong => bold_depth -= 1, + TagEnd::Strikethrough => strikethrough_depth -= 1, + TagEnd::Link => link_url = None, + TagEnd::List(_) => drop(list_stack.pop()), + _ => {} + }, + Event::HardBreak => text.push('\n'), + Event::SoftBreak => text.push('\n'), + _ => {} } } } -/// Check if a string is a URL -fn is_url(s: &str) -> bool { - URL_REGEX.is_match(s) -} +fn new_paragraph(text: &mut String, list_stack: &mut [(Option, bool)]) { + let mut is_subsequent_paragraph_of_list = false; + if let Some((_, has_content)) = list_stack.last_mut() { + if *has_content { + is_subsequent_paragraph_of_list = true; + } else { + *has_content = true; + return; + } + } -/// Format a bech32 entity with ellipsis and last 4 characters -fn format_shortened_entity(entity: &str) -> String { - let prefix_end = entity.find('1').unwrap_or(0); - - if prefix_end > 0 && entity.len() > prefix_end + 5 { - let prefix = &entity[0..=prefix_end]; // Include the '1' - let suffix = &entity[entity.len() - 4..]; // Last 4 chars - - format!("{prefix}...{suffix}") - } else { - entity.to_string() + if !text.is_empty() { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push('\n'); + } + for _ in 0..list_stack.len().saturating_sub(1) { + text.push_str(" "); + } + if is_subsequent_paragraph_of_list { + text.push_str(" "); } } +/* fn render_pubkey( public_key: PublicKey, text: &mut String, @@ -321,3 +396,4 @@ fn adjust_ranges( } } } +*/ diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 5f19e54..e0d42c4 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -2,8 +2,10 @@ pub use debounced_delay::*; pub use display::*; pub use event::*; pub use paths::*; +pub use range::*; mod debounced_delay; mod display; mod event; mod paths; +mod range; diff --git a/crates/common/src/range.rs b/crates/common/src/range.rs new file mode 100644 index 0000000..d0692e4 --- /dev/null +++ b/crates/common/src/range.rs @@ -0,0 +1,45 @@ +use std::cmp::{self}; +use std::ops::{Range, RangeInclusive}; + +pub trait RangeExt { + fn sorted(&self) -> Self; + fn to_inclusive(&self) -> RangeInclusive; + fn overlaps(&self, other: &Range) -> bool; + fn contains_inclusive(&self, other: &Range) -> bool; +} + +impl RangeExt for Range { + fn sorted(&self) -> Self { + cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone() + } + + fn to_inclusive(&self) -> RangeInclusive { + self.start.clone()..=self.end.clone() + } + + fn overlaps(&self, other: &Range) -> bool { + self.start < other.end && other.start < self.end + } + + fn contains_inclusive(&self, other: &Range) -> bool { + self.start <= other.start && other.end <= self.end + } +} + +impl RangeExt for RangeInclusive { + fn sorted(&self) -> Self { + cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone() + } + + fn to_inclusive(&self) -> RangeInclusive { + self.clone() + } + + fn overlaps(&self, other: &Range) -> bool { + self.start() < &other.end && &other.start <= self.end() + } + + fn contains_inclusive(&self, other: &Range) -> bool { + self.start() <= &other.start && &other.end <= self.end() + } +} diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index 71cd1fe..d75691e 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -5,14 +5,14 @@ use chat::{ChatEvent, ChatRegistry, InboxState}; use device::DeviceRegistry; use gpui::prelude::FluentBuilder; use gpui::{ - div, px, Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, Styled, Subscription, Window, + Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement, + Render, SharedString, Styled, Subscription, Window, div, px, }; use person::PersonRegistry; use serde::Deserialize; -use smallvec::{smallvec, SmallVec}; +use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, RelayState, SignerEvent}; -use theme::{ActiveTheme, Theme, ThemeRegistry, SIDEBAR_WIDTH}; +use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; use title_bar::TitleBar; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -21,14 +21,13 @@ use ui::dock_area::panel::PanelView; use ui::dock_area::{ClosePanel, DockArea, DockItem}; use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::Notification; -use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension}; +use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::{accounts, settings}; use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; use crate::sidebar; -const ENC_MSG: &str = - "Encryption Key is a special key that used to encrypt and decrypt your messages. \ +const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \ Your identity is completely decoupled from all encryption processes to protect your privacy."; const ENC_WARN: &str = "By resetting your encryption key, you will lose access to \ diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index b3ae977..9db3700 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -2,10 +2,10 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - canvas, div, point, px, size, AnyView, App, AppContext, Bounds, Context, CursorStyle, - Decorations, Edges, Entity, FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled, - Tiling, WeakFocusHandle, Window, + AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, Entity, + FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton, + ParentElement as _, Pixels, Point, Render, ResizeEdge, SharedString, Size, Styled, Tiling, + WeakFocusHandle, Window, canvas, div, point, px, size, }; use theme::{ ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING, diff --git a/rustfmt.toml b/rustfmt.toml index 22fe2c0..f1db6d0 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,3 +1,5 @@ +edition = "2024" +style_edition = "2024" tab_spaces = 4 newline_style = "Auto" reorder_imports = true