migrate ui components to gpui-kit
This commit is contained in:
+111
-93
@@ -9,12 +9,22 @@ use common::{TimestampExt, emojis_from_tags};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, ImageSource, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState,
|
||||
MouseButton, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
||||
retain_all, svg, white,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::avatar::Avatar;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||
use gpui_kit::component::menu::DropdownMenu;
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::tooltip::Tooltip;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person};
|
||||
@@ -24,21 +34,12 @@ use smallvec::{SmallVec, smallvec};
|
||||
use state::{
|
||||
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
||||
};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||
use ui::menu::DropdownMenu;
|
||||
use ui::message::{
|
||||
MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions, opens_run,
|
||||
};
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::tooltip::Tooltip;
|
||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
use ui::{TextStyle, Typeset as _};
|
||||
|
||||
use crate::file::*;
|
||||
use crate::message::{
|
||||
MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions, opens_run,
|
||||
};
|
||||
use crate::text::RenderedText;
|
||||
|
||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||
@@ -59,6 +60,7 @@ static CUSTOM_EMOJI_RE: LazyLock<Regex> =
|
||||
|
||||
mod actions;
|
||||
mod file;
|
||||
pub mod message;
|
||||
pub mod text;
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
@@ -126,6 +128,11 @@ pub struct ChatPanel {
|
||||
}
|
||||
|
||||
impl ChatPanel {
|
||||
/// The room this panel displays, used to dedupe open tabs.
|
||||
pub fn room_id(&self) -> &SharedString {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// Define attachments and replies_to entities
|
||||
let attachments = cx.new(|_| vec![]);
|
||||
@@ -916,7 +923,7 @@ impl ChatPanel {
|
||||
let seen_on = chat.read(cx).rumor_seen_on(id);
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.title("Seen on").show_close(true).child(
|
||||
this.title("Seen on").close_button(true).child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.when_none(&seen_on, |this| {
|
||||
@@ -925,8 +932,8 @@ impl ChatPanel {
|
||||
.h_10()
|
||||
.justify_center()
|
||||
.text_style(TextStyle::Callout)
|
||||
.bg(cx.theme().surface_raised)
|
||||
.rounded(px(theme::panel_radius()))
|
||||
.bg(cx.theme().secondary)
|
||||
.rounded(px(ui::panel_radius()))
|
||||
.child("Message isn't traced yet"),
|
||||
)
|
||||
})
|
||||
@@ -939,9 +946,9 @@ impl ChatPanel {
|
||||
h_flex()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.gap(px(ui::SPACE))
|
||||
.bg(cx.theme().secondary)
|
||||
.rounded(px(ui::control_radius()))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
@@ -962,7 +969,7 @@ impl ChatPanel {
|
||||
let relays = profile.messaging_relays();
|
||||
|
||||
this.title("Messaging Relays")
|
||||
.show_close(true)
|
||||
.close_button(true)
|
||||
.child(v_flex().gap_1().children({
|
||||
let mut items = vec![];
|
||||
|
||||
@@ -971,9 +978,9 @@ impl ChatPanel {
|
||||
h_flex()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.gap(px(ui::SPACE))
|
||||
.bg(cx.theme().secondary)
|
||||
.rounded(px(ui::control_radius()))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
@@ -996,7 +1003,7 @@ impl ChatPanel {
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().surface_raised),
|
||||
.text_color(cx.theme().secondary),
|
||||
)
|
||||
.message(PRIVATE_NOTICE)
|
||||
.into_any_element()
|
||||
@@ -1012,13 +1019,13 @@ impl ChatPanel {
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.line_height(relative(1.3))
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().element_active),
|
||||
.text_color(cx.theme().list_active),
|
||||
)
|
||||
.child(PRIVATE_NOTICE)
|
||||
.into_any_element()
|
||||
@@ -1041,9 +1048,9 @@ impl ChatPanel {
|
||||
.size_8()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().warning_muted)
|
||||
.bg(cx.theme().warning.opacity(0.15))
|
||||
.text_color(cx.theme().warning)
|
||||
.child(Icon::new(IconName::Warning).small()),
|
||||
.child(Icon::new(IconName::TriangleAlert).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -1146,10 +1153,14 @@ impl ChatPanel {
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0()
|
||||
Button::new("message-avatar")
|
||||
.ghost()
|
||||
.relative()
|
||||
.flex_shrink_0()
|
||||
.child(match author.avatar() {
|
||||
Some(picture) => Avatar::new().src(picture).into_any_element(),
|
||||
None => Avatar::new().into_any_element(),
|
||||
})
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.menu("Public Key", Box::new(Command::Copy(pk)))
|
||||
.menu("View Relays", Box::new(Command::Relays(pk)))
|
||||
@@ -1173,7 +1184,7 @@ impl ChatPanel {
|
||||
.when(has_reactions, |this| this.child(reactions))
|
||||
.overlay(
|
||||
div()
|
||||
.group_hover("", |this| this.bg(cx.theme().element_active))
|
||||
.group_hover("", |this| this.bg(cx.theme().list_active))
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
@@ -1247,7 +1258,7 @@ impl ChatPanel {
|
||||
div()
|
||||
.id(SharedString::from(id.to_hex()))
|
||||
.child(label)
|
||||
.when(failed, |this| this.text_color(cx.theme().danger_muted))
|
||||
.when(failed, |this| this.text_color(cx.theme().danger))
|
||||
.when_some(reports, |this, reports| {
|
||||
this.when(!pending, |this| {
|
||||
this.on_click(move |_e, window, cx| {
|
||||
@@ -1265,7 +1276,7 @@ impl ChatPanel {
|
||||
.unwrap_or_default();
|
||||
|
||||
this.title(SharedString::from("Sent Reports"))
|
||||
.show_close(true)
|
||||
.close_button(true)
|
||||
.child(v_flex().gap_4().children(items))
|
||||
});
|
||||
})
|
||||
@@ -1279,18 +1290,23 @@ impl ChatPanel {
|
||||
let avatar = profile.avatar();
|
||||
|
||||
v_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.gap(px(ui::SPACE))
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.gap(px(ui::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(SharedString::from("Sent to:"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.font_semibold()
|
||||
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.child(match avatar {
|
||||
Some(picture) => {
|
||||
Avatar::new().src(picture).small().into_any_element()
|
||||
}
|
||||
None => Avatar::new().small().into_any_element(),
|
||||
})
|
||||
.child(name.clone()),
|
||||
),
|
||||
)
|
||||
@@ -1303,8 +1319,8 @@ impl ChatPanel {
|
||||
.h_16()
|
||||
.w_full()
|
||||
.text_style(TextStyle::Callout)
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.bg(cx.theme().warning_muted)
|
||||
.rounded(px(ui::surface_radius()))
|
||||
.bg(cx.theme().warning.opacity(0.15))
|
||||
.text_color(cx.theme().warning)
|
||||
.child(div().flex_1().w_full().text_center().child(error)),
|
||||
)
|
||||
@@ -1312,7 +1328,7 @@ impl ChatPanel {
|
||||
.when_some(report.output().cloned(), |this, output| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.gap(px(ui::SPACE))
|
||||
.w_full()
|
||||
.children({
|
||||
let mut items = Vec::with_capacity(output.failed.len());
|
||||
@@ -1323,19 +1339,19 @@ impl ChatPanel {
|
||||
.gap_0p5()
|
||||
.p_1()
|
||||
.w_full()
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.rounded(px(ui::surface_radius()))
|
||||
.bg(cx.theme().danger.opacity(0.06))
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().danger_muted.opacity(0.9))
|
||||
.text_color(cx.theme().danger.opacity(0.9))
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(msg.to_string())),
|
||||
),
|
||||
@@ -1353,12 +1369,12 @@ impl ChatPanel {
|
||||
.gap_0p5()
|
||||
.p_1()
|
||||
.w_full()
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.bg(cx.theme().surface_card)
|
||||
.rounded(px(ui::surface_radius()))
|
||||
.bg(cx.theme().muted)
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(url.0.to_string())),
|
||||
)
|
||||
@@ -1392,10 +1408,10 @@ impl ChatPanel {
|
||||
.right_4()
|
||||
.top_neg_2()
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.rounded(px(ui::surface_radius()))
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().bg)
|
||||
.bg(cx.theme().background)
|
||||
.children({
|
||||
let mut items = vec![];
|
||||
|
||||
@@ -1472,7 +1488,7 @@ impl ChatPanel {
|
||||
img(url.as_str())
|
||||
.size_16()
|
||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||
.rounded(px(theme::control_radius()))
|
||||
.rounded(px(ui::control_radius()))
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
.child(
|
||||
@@ -1486,7 +1502,7 @@ impl ChatPanel {
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(red())
|
||||
.child(Icon::new(IconName::Close).size_2().text_color(white())),
|
||||
.child(Icon::new(IconName::X).size_2().text_color(white())),
|
||||
)
|
||||
.on_click({
|
||||
let url = url.clone();
|
||||
@@ -1530,10 +1546,10 @@ impl ChatPanel {
|
||||
.child(
|
||||
img(path.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_faint)
|
||||
.border_color(cx.theme().border)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(px(theme::bubble_radius())),
|
||||
.rounded(px(ui::bubble_radius())),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
@@ -1561,12 +1577,12 @@ impl ChatPanel {
|
||||
.self_start()
|
||||
.items_start()
|
||||
.min_w_0()
|
||||
.gap(px(theme::SPACE))
|
||||
.gap(px(ui::SPACE))
|
||||
.p_2()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_faint)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.child(Icon::new(IconName::Lock).text_color(cx.theme().accent))
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(ui::control_radius()))
|
||||
.child(Icon::new(IconName::Lock).text_color(cx.theme().primary))
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
@@ -1576,7 +1592,7 @@ impl ChatPanel {
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(label),
|
||||
),
|
||||
)
|
||||
@@ -1600,14 +1616,14 @@ impl ChatPanel {
|
||||
.id(SharedString::from(file.url.to_string()))
|
||||
.relative()
|
||||
.w_16()
|
||||
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
|
||||
.tooltip(move |window, cx| Tooltip::new(label.clone()).build(window, cx))
|
||||
.map(|this| {
|
||||
if file.is_image() {
|
||||
this.child(
|
||||
img(pending.path.clone())
|
||||
.size_16()
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.rounded(px(theme::control_radius()))
|
||||
.rounded(px(ui::control_radius()))
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
} else {
|
||||
@@ -1617,10 +1633,10 @@ impl ChatPanel {
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(px(theme::control_radius()))
|
||||
.rounded(px(ui::control_radius()))
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_faint)
|
||||
.bg(cx.theme().surface)
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_center()
|
||||
.child("Preview not available"),
|
||||
@@ -1637,7 +1653,7 @@ impl ChatPanel {
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_faint)
|
||||
.border_color(cx.theme().border)
|
||||
.bg(gpui::green())
|
||||
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
|
||||
)
|
||||
@@ -1671,7 +1687,7 @@ impl ChatPanel {
|
||||
.w_full()
|
||||
.pl_2()
|
||||
.border_l_2()
|
||||
.border_color(cx.theme().element_active)
|
||||
.border_color(cx.theme().list_active)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
@@ -1681,13 +1697,13 @@ impl ChatPanel {
|
||||
.items_baseline()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Replying to:")
|
||||
.child(div().text_color(cx.theme().accent).child(profile.name())),
|
||||
.child(div().text_color(cx.theme().primary).child(profile.name())),
|
||||
)
|
||||
.child(
|
||||
Button::new("remove-reply")
|
||||
.icon(IconName::Close)
|
||||
.icon(IconName::X)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click({
|
||||
@@ -1784,15 +1800,13 @@ impl ChatPanel {
|
||||
.unwrap_or_default();
|
||||
|
||||
Button::new("emoji")
|
||||
.icon(IconName::Emoji)
|
||||
.icon(IconName::Sticker)
|
||||
.ghost()
|
||||
.large()
|
||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||
let menu = REACTION_EMOJIS
|
||||
.iter()
|
||||
.fold(this.horizontal(), |this, emoji| {
|
||||
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||
});
|
||||
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
|
||||
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||
});
|
||||
|
||||
emojis.iter().fold(menu, |this, (shortcode, _)| {
|
||||
let label = format!(":{shortcode}:");
|
||||
@@ -1802,22 +1816,26 @@ impl ChatPanel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for ChatPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
impl gpui_kit::component::dock::BasePanel for ChatPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"ChatPanel"
|
||||
}
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
impl Panel for ChatPanel {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.room
|
||||
.read_with(cx, |this, cx| {
|
||||
let label = this.display_name(cx);
|
||||
let picture = this.display_image(cx);
|
||||
let seed = this.display_image_seed(cx);
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Caption)
|
||||
.child(Avatar::new(picture).seed(seed).xsmall())
|
||||
.child(match picture {
|
||||
Some(picture) => Avatar::new().src(picture).xsmall().into_any_element(),
|
||||
None => Avatar::new().xsmall().into_any_element(),
|
||||
})
|
||||
.child(label)
|
||||
.into_any_element()
|
||||
})
|
||||
@@ -1829,12 +1847,16 @@ impl Panel for ChatPanel {
|
||||
)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
fn toolbar_buttons(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Vec<Button>> {
|
||||
let subject_bar = self.subject_bar.clone();
|
||||
|
||||
vec![
|
||||
Some(vec![
|
||||
Button::new("subject")
|
||||
.icon(IconName::Input)
|
||||
.icon(IconName::TextCursorInput)
|
||||
.tooltip("Change subject")
|
||||
.small()
|
||||
.ghost()
|
||||
@@ -1844,11 +1866,7 @@ impl Panel for ChatPanel {
|
||||
cx.notify();
|
||||
});
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn background(&self, cx: &App) -> Option<ImageSource> {
|
||||
AppSettings::panel_background_source(cx)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1877,13 +1895,13 @@ impl Render for ChatPanel {
|
||||
.h_12()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.gap(px(ui::SPACE))
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(Input::new(&self.subject_input).small())
|
||||
.child(
|
||||
Button::new("change")
|
||||
.icon(IconName::CheckCircle)
|
||||
.icon(IconName::CircleCheck)
|
||||
.label("Change")
|
||||
.secondary()
|
||||
.disabled(self.uploading)
|
||||
@@ -1962,7 +1980,7 @@ impl Render for ChatPanel {
|
||||
.child(self.render_config_menu(window, cx))
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::PaperPlaneFill)
|
||||
.icon(IconName::SendHorizontal)
|
||||
.disabled(self.uploading)
|
||||
.ghost()
|
||||
.large()
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
|
||||
MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri,
|
||||
StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px,
|
||||
};
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, InteractiveElementExt as _, StyledExt as _, h_flex, v_flex,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use ui::{TextStyle, Typeset as _, bubble_radius, control_radius};
|
||||
|
||||
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
||||
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||
|
||||
/// Messages from the same author within this window are grouped into one run.
|
||||
pub const RUN_WINDOW_SECS: u64 = 300;
|
||||
|
||||
/// Whether a message starts a new run, given the previous message's author and unix time.
|
||||
pub fn opens_run<T: PartialEq>(previous: Option<(T, u64)>, current: (T, u64)) -> bool {
|
||||
match previous {
|
||||
None => true,
|
||||
Some((previous_author, previous_at)) => {
|
||||
previous_author != current.0 || current.1.saturating_sub(previous_at) > RUN_WINDOW_SECS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single message row in a chat-like list.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MessageRow {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
show_author: bool,
|
||||
hide_avatar: bool,
|
||||
avatar: Option<AnyElement>,
|
||||
author: Option<SharedString>,
|
||||
timestamp: Option<SharedString>,
|
||||
header_extras: SmallVec<[AnyElement; 1]>,
|
||||
body: SmallVec<[AnyElement; 4]>,
|
||||
overlays: SmallVec<[AnyElement; 2]>,
|
||||
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
|
||||
on_double_click: Option<ClickListener>,
|
||||
}
|
||||
|
||||
impl MessageRow {
|
||||
/// Create a message row with the given element id.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
show_author: false,
|
||||
hide_avatar: false,
|
||||
avatar: None,
|
||||
author: None,
|
||||
timestamp: None,
|
||||
header_extras: SmallVec::new(),
|
||||
body: SmallVec::new(),
|
||||
overlays: SmallVec::new(),
|
||||
on_mouse_down: None,
|
||||
on_double_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this row opens a run of messages from one author.
|
||||
#[must_use]
|
||||
pub fn show_author(mut self, show_author: bool) -> Self {
|
||||
self.show_author = show_author;
|
||||
self
|
||||
}
|
||||
|
||||
/// Hide the avatar column entirely.
|
||||
#[must_use]
|
||||
pub fn hide_avatar(mut self, hide_avatar: bool) -> Self {
|
||||
self.hide_avatar = hide_avatar;
|
||||
self
|
||||
}
|
||||
|
||||
/// The avatar shown when this row opens a run of messages.
|
||||
#[must_use]
|
||||
pub fn avatar(mut self, avatar: impl IntoElement) -> Self {
|
||||
self.avatar = Some(avatar.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// The author's display name, shown next to the timestamp.
|
||||
#[must_use]
|
||||
pub fn author(mut self, author: impl Into<SharedString>) -> Self {
|
||||
self.author = Some(author.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The time the message was sent.
|
||||
#[must_use]
|
||||
pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
|
||||
self.timestamp = Some(timestamp.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an element to the header row.
|
||||
#[must_use]
|
||||
pub fn header_extra(mut self, extra: impl IntoElement) -> Self {
|
||||
self.header_extras.push(extra.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an element to the message body, in order.
|
||||
#[must_use]
|
||||
pub fn child(mut self, child: impl IntoElement) -> Self {
|
||||
self.body.push(child.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Append several elements to the message body, in order.
|
||||
#[must_use]
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = impl IntoElement>) -> Self {
|
||||
self.body
|
||||
.extend(children.into_iter().map(|child| child.into_any_element()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an element positioned over the row.
|
||||
#[must_use]
|
||||
pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
|
||||
self.overlays.push(overlay.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Handle a mouse button press anywhere on the row.
|
||||
#[must_use]
|
||||
pub fn on_mouse_down(
|
||||
mut self,
|
||||
button: MouseButton,
|
||||
listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_mouse_down = Some((button, Box::new(listener)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Handle a double click anywhere on the row.
|
||||
#[must_use]
|
||||
pub fn on_double_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_double_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for MessageRow {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MessageRow {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self {
|
||||
id,
|
||||
style,
|
||||
show_author,
|
||||
hide_avatar,
|
||||
avatar,
|
||||
author,
|
||||
timestamp,
|
||||
header_extras,
|
||||
body,
|
||||
overlays,
|
||||
on_mouse_down,
|
||||
on_double_click,
|
||||
} = self;
|
||||
|
||||
div()
|
||||
.id(id)
|
||||
.group("")
|
||||
.relative()
|
||||
.w_full()
|
||||
.py_1()
|
||||
.when(show_author, |this| this.pt_2())
|
||||
.px_3()
|
||||
.refine_style(&style)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_3()
|
||||
.when(!hide_avatar, |this| {
|
||||
if show_author {
|
||||
match avatar {
|
||||
Some(avatar) => this.child(avatar),
|
||||
None => this.child(div().flex_shrink_0().w(px(32.))),
|
||||
}
|
||||
} else {
|
||||
this.child(div().flex_shrink_0().w(px(32.)))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.when(show_author, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap(px(ui::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.when_some(author, |this, author| {
|
||||
this.child(
|
||||
div()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.child(author),
|
||||
)
|
||||
})
|
||||
.when_some(timestamp, |this, timestamp| {
|
||||
this.child(timestamp)
|
||||
})
|
||||
.children(header_extras),
|
||||
)
|
||||
})
|
||||
.children(body),
|
||||
),
|
||||
)
|
||||
.children(overlays)
|
||||
.when_some(on_mouse_down, |this, (button, listener)| {
|
||||
this.on_mouse_down(button, listener)
|
||||
})
|
||||
.when_some(on_double_click, |this, listener| {
|
||||
this.on_double_click(listener)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().accent))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// A quoted preview of a message this message replies to.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReplyPreview {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
author: SharedString,
|
||||
preview: SharedString,
|
||||
on_click: Option<ClickListener>,
|
||||
}
|
||||
|
||||
impl ReplyPreview {
|
||||
/// Create a reply preview with the given element id, author and snippet.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
author: impl Into<SharedString>,
|
||||
preview: impl Into<SharedString>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
author: author.into(),
|
||||
preview: preview.into(),
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a click anywhere on the preview.
|
||||
#[must_use]
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ReplyPreview {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReplyPreview {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self {
|
||||
id,
|
||||
style,
|
||||
author,
|
||||
preview,
|
||||
on_click,
|
||||
} = self;
|
||||
|
||||
div()
|
||||
.id(id)
|
||||
.w_full()
|
||||
.px_2()
|
||||
.border_l_2()
|
||||
.border_color(cx.theme().list_active)
|
||||
.text_style(TextStyle::Callout)
|
||||
.refine_style(&style)
|
||||
.child(div().font_weight(gpui::FontWeight::SEMIBOLD).child(author))
|
||||
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview))
|
||||
.hover(|this| this.bg(cx.theme().secondary))
|
||||
.when_some(on_click, |this, on_click| this.on_click(on_click))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// The image media of a message: one large image or a wrapping row of thumbnails.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MessageMedia {
|
||||
style: StyleRefinement,
|
||||
media: Vec<SharedUri>,
|
||||
}
|
||||
|
||||
impl MessageMedia {
|
||||
/// Create media from image URLs.
|
||||
pub fn new(media: &[SharedUri]) -> Self {
|
||||
Self {
|
||||
style: StyleRefinement::default(),
|
||||
media: media.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for MessageMedia {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MessageMedia {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self { style, media } = self;
|
||||
|
||||
if media.is_empty() {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
if let [only] = media.as_slice() {
|
||||
return div()
|
||||
.refine_style(&style)
|
||||
.child(
|
||||
img(only.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(px(bubble_radius())),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_wrap()
|
||||
.gap(px(ui::SPACE))
|
||||
.refine_style(&style)
|
||||
.children(media.into_iter().enumerate().map(|(ix, item)| {
|
||||
div().id(format!("media-{ix}")).flex_shrink_0().child(
|
||||
img(item)
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(bubble_radius())),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reactions of one emoji with the count of authors and whether the current user reacted.
|
||||
pub struct ReactionGroup {
|
||||
pub emoji: SharedString,
|
||||
/// Image of a custom emoji (NIP-30) reaction
|
||||
pub image: Option<SharedUri>,
|
||||
pub count: usize,
|
||||
pub mine: bool,
|
||||
}
|
||||
|
||||
/// Group (emoji, image, author) triples by emoji, sorted by emoji.
|
||||
pub fn group_reactions<A: PartialEq>(
|
||||
reactions: impl IntoIterator<Item = (SharedString, Option<SharedUri>, A)>,
|
||||
me: Option<A>,
|
||||
) -> Vec<ReactionGroup> {
|
||||
let mut groups: BTreeMap<SharedString, (usize, bool, Option<SharedUri>)> = BTreeMap::new();
|
||||
|
||||
for (emoji, image, author) in reactions {
|
||||
let group = groups.entry(emoji).or_default();
|
||||
group.0 += 1;
|
||||
group.1 |= me.as_ref() == Some(&author);
|
||||
if group.2.is_none() {
|
||||
group.2 = image;
|
||||
}
|
||||
}
|
||||
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|(emoji, (count, mine, image))| ReactionGroup {
|
||||
emoji,
|
||||
image,
|
||||
count,
|
||||
mine,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reaction chips shown under a message.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ReactionBar {
|
||||
style: StyleRefinement,
|
||||
groups: SmallVec<[ReactionGroup; 4]>,
|
||||
}
|
||||
|
||||
impl ReactionBar {
|
||||
/// Create a reaction bar from grouped reactions.
|
||||
pub fn new(groups: impl IntoIterator<Item = ReactionGroup>) -> Self {
|
||||
Self {
|
||||
style: StyleRefinement::default(),
|
||||
groups: groups.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ReactionBar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ReactionBar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self { style, groups } = self;
|
||||
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
.refine_style(&style)
|
||||
.children(groups.into_iter().map(|group| {
|
||||
h_flex()
|
||||
.gap(px(ui::SPACE))
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.rounded(px(control_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.border_1()
|
||||
.when(group.mine, |this| {
|
||||
this.text_color(cx.theme().foreground)
|
||||
.bg(cx.theme().secondary)
|
||||
.border_color(cx.theme().list_active)
|
||||
})
|
||||
.when(!group.mine, |this| this.border_color(cx.theme().border))
|
||||
.when(group.image.is_none(), |this| this.child(group.emoji))
|
||||
.when_some(group.image, |this, image| {
|
||||
this.child(img(image).size(px(16.)).flex_shrink_0())
|
||||
})
|
||||
.child(SharedString::from(group.count.to_string()))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A welcome message shown at the top of a message list.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WelcomeMessage {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: Option<AnyElement>,
|
||||
title: Option<SharedString>,
|
||||
message: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl WelcomeMessage {
|
||||
/// Create a welcome message with the given element id.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
icon: None,
|
||||
title: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon shown above the title.
|
||||
#[must_use]
|
||||
pub fn icon(mut self, icon: impl IntoElement) -> Self {
|
||||
self.icon = Some(icon.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// The welcome title.
|
||||
#[must_use]
|
||||
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The welcome body text.
|
||||
#[must_use]
|
||||
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for WelcomeMessage {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WelcomeMessage {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self {
|
||||
id,
|
||||
style,
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
} = self;
|
||||
|
||||
v_flex()
|
||||
.id(id)
|
||||
.w_full()
|
||||
.gap(px(ui::SPACE))
|
||||
.p_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.refine_style(&style)
|
||||
.when_some(icon, |this, icon| this.child(icon))
|
||||
.child(
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.when_some(title, |this, title| {
|
||||
this.child(
|
||||
div()
|
||||
.text_style(TextStyle::Headline)
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(title),
|
||||
)
|
||||
})
|
||||
.when_some(message, |this, message| {
|
||||
this.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(message),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
+119
-3
@@ -1,9 +1,125 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use chat::{Mention, nevent_refs};
|
||||
use gpui::{App, Entity};
|
||||
use gpui::{AnyElement, App, ElementId, Entity, IntoElement as _, SharedString, SharedUri, Window};
|
||||
use gpui_kit::component::text::TextView;
|
||||
use nostr_sdk::prelude::Url;
|
||||
use person::Directory;
|
||||
use ui::markdown::InlineReplacement;
|
||||
pub use ui::markdown::RenderedText;
|
||||
|
||||
/// A span of the source content, by byte range, that renders as `text` instead.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InlineReplacement {
|
||||
pub range: Range<usize>,
|
||||
pub text: SharedString,
|
||||
/// When set, the span renders as this image (NIP-30 custom emoji)
|
||||
pub image: Option<SharedUri>,
|
||||
}
|
||||
|
||||
impl InlineReplacement {
|
||||
pub fn new(range: Range<usize>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
range,
|
||||
text: text.into(),
|
||||
image: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn image(
|
||||
range: Range<usize>,
|
||||
alt: impl Into<SharedString>,
|
||||
url: impl Into<SharedUri>,
|
||||
) -> Self {
|
||||
Self {
|
||||
range,
|
||||
text: alt.into(),
|
||||
image: Some(url.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Message content prepared for rendering as a GPUI Kit text view.
|
||||
#[derive(Default)]
|
||||
pub struct RenderedText {
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
pub fn new(content: &str, replacements: &[InlineReplacement]) -> Self {
|
||||
let mut text = String::with_capacity(content.len());
|
||||
let mut last = 0;
|
||||
|
||||
for replacement in replacements {
|
||||
let range = &replacement.range;
|
||||
if range.start < last
|
||||
|| range.end > content.len()
|
||||
|| range.start >= range.end
|
||||
|| !content.is_char_boundary(range.start)
|
||||
|| !content.is_char_boundary(range.end)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
text.push_str(&content[last..range.start]);
|
||||
match &replacement.image {
|
||||
Some(url) => text.push_str(&format!(
|
||||
"",
|
||||
escape_link_text(&replacement.text)
|
||||
)),
|
||||
None => text.push_str(&format!(
|
||||
"[{}](<{}>)",
|
||||
escape_link_text(&replacement.text),
|
||||
&content[range.start..range.end]
|
||||
)),
|
||||
}
|
||||
last = range.end;
|
||||
}
|
||||
text.push_str(&content[last..]);
|
||||
|
||||
Self {
|
||||
text: SharedString::from(text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the message as a GPUI Kit text view.
|
||||
///
|
||||
/// The framework Root installs a theme-derived base style for text views,
|
||||
/// so only the Nostr link handling is customized here.
|
||||
pub fn element(&self, id: ElementId, _window: &Window, _cx: &App) -> AnyElement {
|
||||
TextView::markdown(id, self.text.clone())
|
||||
.on_link_click(|url, _, _, cx| {
|
||||
if is_web_url(url) {
|
||||
cx.open_url(url);
|
||||
} else if let Some(bech32) = nevent_bech32(url) {
|
||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes the characters that would terminate a markdown link label.
|
||||
fn escape_link_text(text: &str) -> String {
|
||||
let mut escaped = String::with_capacity(text.len());
|
||||
for character in text.chars() {
|
||||
if matches!(character, '[' | ']' | '\\') {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped.push(character);
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
||||
fn is_web_url(url: &str) -> bool {
|
||||
let url = url.to_ascii_lowercase();
|
||||
url.starts_with("http://") || url.starts_with("https://")
|
||||
}
|
||||
|
||||
/// Extracts the bech32 from a NIP-19 event reference (`nevent1...` or `nostr:nevent1...`).
|
||||
fn nevent_bech32(url: &str) -> Option<&str> {
|
||||
let bech32 = url.strip_prefix("nostr:").unwrap_or(url);
|
||||
bech32.starts_with("nevent1").then_some(bech32)
|
||||
}
|
||||
|
||||
pub fn rendered_text(
|
||||
content: &str,
|
||||
|
||||
Reference in New Issue
Block a user