new text parser
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 2m39s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled

This commit is contained in:
2026-03-03 09:25:24 +07:00
parent 55c5ebbf17
commit 29c7a82d1d
9 changed files with 336 additions and 171 deletions

39
Cargo.lock generated
View File

@@ -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"

View File

@@ -28,3 +28,5 @@ serde_json.workspace = true
once_cell = "1.19.0"
regex = "1"
linkify = "0.10.0"
pulldown-cmark = "0.13.1"

View File

@@ -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)

View File

@@ -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<Regex> = 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<Regex> =
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<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Highlight(style)
}
}
#[derive(Debug)]
pub struct Mention {
pub range: Range<usize>,
}
#[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<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
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<usize>, 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<usize>, 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<u64>, 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(
}
}
}
*/

View File

@@ -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;

View File

@@ -0,0 +1,45 @@
use std::cmp::{self};
use std::ops::{Range, RangeInclusive};
pub trait RangeExt<T> {
fn sorted(&self) -> Self;
fn to_inclusive(&self) -> RangeInclusive<T>;
fn overlaps(&self, other: &Range<T>) -> bool;
fn contains_inclusive(&self, other: &Range<T>) -> bool;
}
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
fn sorted(&self) -> Self {
cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone()
}
fn to_inclusive(&self) -> RangeInclusive<T> {
self.start.clone()..=self.end.clone()
}
fn overlaps(&self, other: &Range<T>) -> bool {
self.start < other.end && other.start < self.end
}
fn contains_inclusive(&self, other: &Range<T>) -> bool {
self.start <= other.start && other.end <= self.end
}
}
impl<T: Ord + Clone> RangeExt<T> for RangeInclusive<T> {
fn sorted(&self) -> Self {
cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone()
}
fn to_inclusive(&self) -> RangeInclusive<T> {
self.clone()
}
fn overlaps(&self, other: &Range<T>) -> bool {
self.start() < &other.end && &other.start <= self.end()
}
fn contains_inclusive(&self, other: &Range<T>) -> bool {
self.start() <= &other.start && &other.end <= self.end()
}
}

View File

@@ -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 \

View File

@@ -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,

View File

@@ -1,3 +1,5 @@
edition = "2024"
style_edition = "2024"
tab_spaces = 4
newline_style = "Auto"
reorder_imports = true