From 7a6b6feaccbee83f6aa866491c0e3278281073e1 Mon Sep 17 00:00:00 2001 From: reya Date: Tue, 3 Mar 2026 08:55:36 +0000 Subject: [PATCH] feat: refactor the text parser (#15) Fix: https://jumble.social/notes/nevent1qvzqqqqqqypzqwlsccluhy6xxsr6l9a9uhhxf75g85g8a709tprjcn4e42h053vaqyvhwumn8ghj7un9d3shjtnjv4ukztnnw5hkjmnzdauqzrnhwden5te0dehhxtnvdakz7qpqpj4awhj4ul6tztlne0v7efvqhthygt0myrlxslpsjh7t6x4esapq3lf5c0 Reviewed-on: https://git.reya.su/reya/coop/pulls/15 --- Cargo.lock | 40 +++ crates/chat/src/message.rs | 30 ++- crates/chat_ui/Cargo.toml | 2 + crates/chat_ui/src/lib.rs | 23 +- crates/chat_ui/src/text.rs | 484 ++++++++++++++++++++--------------- crates/common/Cargo.toml | 1 + crates/common/src/lib.rs | 4 + crates/common/src/parser.rs | 210 +++++++++++++++ crates/common/src/range.rs | 45 ++++ crates/coop/src/workspace.rs | 13 +- crates/ui/src/root.rs | 8 +- rustfmt.toml | 2 + 12 files changed, 626 insertions(+), 236 deletions(-) create mode 100644 crates/common/src/parser.rs create mode 100644 crates/common/src/range.rs diff --git a/Cargo.lock b/Cargo.lock index 3ebe66f..61b4f7a 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", @@ -1241,6 +1243,7 @@ name = "common" version = "1.0.0-beta" dependencies = [ "anyhow", + "bech32", "chrono", "dirs 5.0.1", "futures", @@ -2443,6 +2446,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 +3719,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 +4996,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/src/message.rs b/crates/chat/src/message.rs index 6118331..1cbcadd 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -1,6 +1,7 @@ use std::hash::Hash; +use std::ops::Range; -use common::EventUtils; +use common::{EventUtils, NostrParser}; use nostr_sdk::prelude::*; /// New message. @@ -91,6 +92,18 @@ impl PartialOrd for Message { } } +#[derive(Debug, Clone)] +pub struct Mention { + pub public_key: PublicKey, + pub range: Range, +} + +impl Mention { + pub fn new(public_key: PublicKey, range: Range) -> Self { + Self { public_key, range } + } +} + /// Rendered message. #[derive(Debug, Clone)] pub struct RenderedMessage { @@ -102,7 +115,7 @@ pub struct RenderedMessage { /// Message created time as unix timestamp pub created_at: Timestamp, /// List of mentioned public keys in the message - pub mentions: Vec, + pub mentions: Vec, /// List of event of the message this message is a reply to pub replies_to: Vec, } @@ -184,20 +197,17 @@ impl Hash for RenderedMessage { } /// Extracts all mentions (public keys) from a content string. -fn extract_mentions(content: &str) -> Vec { +fn extract_mentions(content: &str) -> Vec { let parser = NostrParser::new(); let tokens = parser.parse(content); tokens - .filter_map(|token| match token { - Token::Nostr(nip21) => match nip21 { - Nip21::Pubkey(pubkey) => Some(pubkey), - Nip21::Profile(profile) => Some(profile.public_key), - _ => None, - }, + .filter_map(|token| match token.value { + Nip21::Pubkey(public_key) => Some(Mention::new(public_key, token.range)), + Nip21::Profile(profile) => Some(Mention::new(profile.public_key, token.range)), _ => None, }) - .collect::>() + .collect() } /// Extracts all reply (ids) from the event tags. 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..37ef7dd 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; @@ -699,10 +699,13 @@ impl ChatPanel { if let Some(message) = self.messages.iter().nth(ix) { match message { Message::User(rendered) => { + let persons = PersonRegistry::global(cx); 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, &rendered.mentions, &persons, cx) + }) .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..982ca11 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -1,29 +1,29 @@ use std::ops::Range; use std::sync::Arc; +use chat::Mention; +use common::RangeExt; use gpui::{ - AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString, - StyledText, UnderlineStyle, Window, + AnyElement, App, ElementId, Entity, 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(Default)] @@ -35,7 +35,12 @@ pub struct RenderedText { } impl RenderedText { - pub fn new(content: &str, cx: &App) -> Self { + pub fn new( + content: &str, + mentions: &[Mention], + persons: &Entity, + cx: &App, + ) -> Self { let mut text = String::new(); let mut highlights = Vec::new(); let mut link_ranges = Vec::new(); @@ -43,10 +48,12 @@ impl RenderedText { render_plain_text_mut( content, + mentions, &mut text, &mut highlights, &mut link_ranges, &mut link_urls, + persons, cx, ); @@ -61,7 +68,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 +78,35 @@ 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 { + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), ..Default::default() }, + Highlight::Highlight(highlight) => *highlight, }, ) }), @@ -87,22 +114,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); } } }) @@ -110,214 +125,273 @@ impl RenderedText { } } +#[allow(clippy::too_many_arguments)] 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, + persons: &Entity, 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(); + match event { + Event::Text(t) => { + // Process text with mention replacements + let t_str = t.as_ref(); + let mut last_processed = 0; - 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(); + while let Some(mention) = mentions.first() { + if !source_range.contains_inclusive(&mention.range) { + break; + } - // 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)); - } - } + // Calculate positions within the current text + let mention_start_in_text = mention.range.start - source_range.start; + let mention_end_in_text = mention.range.end - source_range.start; - // 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); + // Add text before this mention + if mention_start_in_text > last_processed { + let before_mention = &t_str[last_processed..mention_start_in_text]; + process_text_segment( + before_mention, + prev_len + last_processed, + bold_depth, + italic_depth, + strikethrough_depth, + link_url.clone(), + text, + highlights, + link_ranges, + link_urls, + ); + } - // 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 the mention replacement + let profile = persons.read(cx).get(&mention.public_key, cx); + let replacement_text = format!("@{}", profile.name()); - // 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; - }; + let replacement_start = text.len(); + text.push_str(&replacement_text); + let replacement_end = text.len(); - 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, - ); + highlights.push((replacement_start..replacement_end, Highlight::Mention)); + + last_processed = mention_end_in_text; + mentions = &mentions[1..]; } - Nip21::Profile(nip19_profile) => { - render_pubkey( - nip19_profile.public_key, + + // Add any remaining text after the last mention + if last_processed < t_str.len() { + let remaining_text = &t_str[last_processed..]; + process_text_segment( + remaining_text, + prev_len + last_processed, + bold_depth, + italic_depth, + strikethrough_depth, + link_url.clone(), 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, ); } } + 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) -} +#[allow(clippy::too_many_arguments)] +fn process_text_segment( + segment: &str, + segment_start: usize, + bold_depth: i32, + italic_depth: i32, + strikethrough_depth: i32, + link_url: Option, + text: &mut String, + highlights: &mut Vec<(Range, Highlight)>, + link_ranges: &mut Vec>, + link_urls: &mut Vec, +) { + // Build the style for this segment + let mut style = HighlightStyle::default(); + if bold_depth > 0 { + style.font_weight = Some(FontWeight::BOLD); + } + if italic_depth > 0 { + style.font_style = Some(FontStyle::Italic); + } + if strikethrough_depth > 0 { + style.strikethrough = Some(StrikethroughStyle { + thickness: 1.0.into(), + ..Default::default() + }); + } -/// 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); + // Add the text + text.push_str(segment); + let text_end = text.len(); - 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 + if let Some(link_url) = link_url { + // Handle as a markdown link + link_ranges.push(segment_start..text_end); + link_urls.push(link_url); + style.underline = Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }); - format!("{prefix}...{suffix}") + // Add highlight for the entire linked segment + if style != HighlightStyle::default() { + highlights.push((segment_start..text_end, Highlight::Highlight(style))); + } } else { - entity.to_string() - } -} + // Handle link detection within the segment + let mut finder = linkify::LinkFinder::new(); + finder.kinds(&[linkify::LinkKind::Url]); + let mut last_link_pos = 0; -fn render_pubkey( - public_key: PublicKey, - text: &mut String, - range: &Range, - highlights: &mut Vec<(Range, Highlight)>, - link_ranges: &mut Vec>, - link_urls: &mut Vec, - cx: &App, -) { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&public_key, cx); - let display_name = format!("@{}", profile.name()); + for link in finder.links(segment) { + let start = link.start(); + let end = link.end(); - text.replace_range(range.clone(), &display_name); + // Add non-link text before this link + if start > last_link_pos { + let non_link_start = segment_start + last_link_pos; + let non_link_end = segment_start + start; - let new_length = display_name.len(); - let length_diff = new_length as isize - (range.end - range.start) as isize; - let new_range = range.start..(range.start + new_length); + if style != HighlightStyle::default() { + highlights.push((non_link_start..non_link_end, Highlight::Highlight(style))); + } + } - highlights.push((new_range.clone(), Highlight::Nostr)); - link_ranges.push(new_range); - link_urls.push(format!("nostr:{}", profile.public_key().to_hex())); + // Add the link + let range = (segment_start + start)..(segment_start + end); + link_ranges.push(range.clone()); + link_urls.push(link.as_str().to_string()); - if length_diff != 0 { - adjust_ranges(highlights, link_ranges, range.end, length_diff); - } -} + // Apply link styling (underline + existing style) + let mut link_style = style; + link_style.underline = Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }); -fn render_bech32( - bech32: String, - text: &mut String, - range: &Range, - highlights: &mut Vec<(Range, Highlight)>, - link_ranges: &mut Vec>, - link_urls: &mut Vec, -) { - let njump_url = format!("https://njump.me/{bech32}"); - let shortened_entity = format_shortened_entity(&bech32); - let display_text = format!("https://njump.me/{shortened_entity}"); + highlights.push((range, Highlight::Highlight(link_style))); - text.replace_range(range.clone(), &display_text); - - let new_length = display_text.len(); - let length_diff = new_length as isize - (range.end - range.start) as isize; - let new_range = range.start..(range.start + new_length); - - highlights.push((new_range.clone(), Highlight::Link)); - link_ranges.push(new_range); - link_urls.push(njump_url); - - if length_diff != 0 { - adjust_ranges(highlights, link_ranges, range.end, length_diff); - } -} - -// Helper function to adjust ranges when text length changes -fn adjust_ranges( - highlights: &mut [(Range, Highlight)], - link_ranges: &mut [Range], - position: usize, - length_diff: isize, -) { - // Adjust highlight ranges - for (range, _) in highlights.iter_mut() { - if range.start > position { - range.start = (range.start as isize + length_diff) as usize; - range.end = (range.end as isize + length_diff) as usize; + last_link_pos = end; } - } - // Adjust link ranges - for range in link_ranges.iter_mut() { - if range.start > position { - range.start = (range.start as isize + length_diff) as usize; - range.end = (range.end as isize + length_diff) as usize; + // Add any remaining text after the last link + if last_link_pos < segment.len() { + let remaining_start = segment_start + last_link_pos; + let remaining_end = segment_start + segment.len(); + + if style != HighlightStyle::default() { + highlights.push((remaining_start..remaining_end, Highlight::Highlight(style))); + } } } } + +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; + } + } + + 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(" "); + } +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 9e2e6e9..2bac890 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -19,3 +19,4 @@ log.workspace = true dirs = "5.0" qrcode = "0.14.1" +bech32 = "0.11.1" diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 5f19e54..0318b82 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,9 +1,13 @@ pub use debounced_delay::*; pub use display::*; pub use event::*; +pub use parser::*; pub use paths::*; +pub use range::*; mod debounced_delay; mod display; mod event; +mod parser; mod paths; +mod range; diff --git a/crates/common/src/parser.rs b/crates/common/src/parser.rs new file mode 100644 index 0000000..c14bb9e --- /dev/null +++ b/crates/common/src/parser.rs @@ -0,0 +1,210 @@ +use std::ops::Range; + +use nostr::prelude::*; + +const BECH32_SEPARATOR: u8 = b'1'; +const SCHEME_WITH_COLON: &str = "nostr:"; + +/// Nostr parsed token with its range in the original text +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + /// The parsed NIP-21 URI + /// + /// + pub value: Nip21, + /// The range of this token in the original text + pub range: Range, +} + +#[derive(Debug, Clone, Copy)] +struct Match { + start: usize, + end: usize, +} + +/// Nostr parser +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NostrParser; + +impl Default for NostrParser { + fn default() -> Self { + Self::new() + } +} + +impl NostrParser { + /// Create new parser + pub const fn new() -> Self { + Self + } + + /// Parse text + pub fn parse<'a>(&self, text: &'a str) -> NostrParserIter<'a> { + NostrParserIter::new(text) + } +} + +struct FindMatches<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> FindMatches<'a> { + fn new(text: &'a str) -> Self { + Self { + bytes: text.as_bytes(), + pos: 0, + } + } + + fn try_parse_nostr_uri(&mut self) -> Option { + let start = self.pos; + let bytes = self.bytes; + let len = bytes.len(); + + // Check if we have "nostr:" prefix + if len - start < SCHEME_WITH_COLON.len() { + return None; + } + + // Check for "nostr:" prefix (case-insensitive) + let scheme_prefix = &bytes[start..start + SCHEME_WITH_COLON.len()]; + if !scheme_prefix.eq_ignore_ascii_case(SCHEME_WITH_COLON.as_bytes()) { + return None; + } + + // Skip the scheme + let pos = start + SCHEME_WITH_COLON.len(); + + // Parse bech32 entity + let mut has_separator = false; + let mut end = pos; + + while end < len { + let byte = bytes[end]; + + // Check for bech32 separator + if byte == BECH32_SEPARATOR && !has_separator { + has_separator = true; + end += 1; + continue; + } + + // Check if character is valid for bech32 + if !byte.is_ascii_alphanumeric() { + break; + } + + end += 1; + } + + // Must have at least one character after separator + if !has_separator || end <= pos + 1 { + return None; + } + + // Update position + self.pos = end; + + Some(Match { start, end }) + } +} + +impl Iterator for FindMatches<'_> { + type Item = Match; + + fn next(&mut self) -> Option { + while self.pos < self.bytes.len() { + // Try to parse nostr URI + if let Some(mat) = self.try_parse_nostr_uri() { + return Some(mat); + } + + // Skip one character if no match found + self.pos += 1; + } + + None + } +} + +enum HandleMatch { + Token(Token), + Recursion, +} + +pub struct NostrParserIter<'a> { + /// The original text + text: &'a str, + /// Matches found + matches: FindMatches<'a>, + /// A pending match + pending_match: Option, + /// Last match end index + last_match_end: usize, +} + +impl<'a> NostrParserIter<'a> { + fn new(text: &'a str) -> Self { + Self { + text, + matches: FindMatches::new(text), + pending_match: None, + last_match_end: 0, + } + } + + fn handle_match(&mut self, mat: Match) -> HandleMatch { + // Update last match end + self.last_match_end = mat.end; + + // Extract the matched string + let data: &str = &self.text[mat.start..mat.end]; + + // Parse NIP-21 URI + match Nip21::parse(data) { + Ok(uri) => HandleMatch::Token(Token { + value: uri, + range: mat.start..mat.end, + }), + // If the nostr URI parsing is invalid, skip it + Err(_) => HandleMatch::Recursion, + } + } +} + +impl<'a> Iterator for NostrParserIter<'a> { + type Item = Token; + + fn next(&mut self) -> Option { + // Handle a pending match + if let Some(pending_match) = self.pending_match.take() { + return match self.handle_match(pending_match) { + HandleMatch::Token(token) => Some(token), + HandleMatch::Recursion => self.next(), + }; + } + + match self.matches.next() { + Some(mat) => { + // Skip any text before this match + if mat.start > self.last_match_end { + // Update pending match + // This will be handled at next iteration, in `handle_match` method. + self.pending_match = Some(mat); + + // Skip the text before the match + self.last_match_end = mat.start; + return self.next(); + } + + // Handle match + match self.handle_match(mat) { + HandleMatch::Token(token) => Some(token), + HandleMatch::Recursion => self.next(), + } + } + None => None, + } + } +} 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