This commit is contained in:
2026-10-22 21:07:18 +07:00
parent bd9f4e2861
commit c3c8142f06
24 changed files with 398 additions and 296 deletions
+42 -98
View File
@@ -15,7 +15,6 @@ use gpui::{
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
retain_all, svg, white,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent};
@@ -34,16 +33,13 @@ use smallvec::{SmallVec, smallvec};
use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
};
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
opens_run, rendered_text,
};
use crate::file::*;
use crate::message::{
Avatar, MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions,
opens_run,
};
use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
/// Notice shown when a conversation has no messages, and above the message list.
const PRIVATE_NOTICE: &str =
@@ -60,8 +56,6 @@ 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> {
cx.new(|cx| ChatPanel::new(room, window, cx))
@@ -1050,7 +1044,7 @@ impl ChatPanel {
.rounded_full()
.bg(cx.theme().warning.opacity(0.15))
.text_color(cx.theme().warning)
.child(Icon::new(IconName::TriangleAlert).small()),
.child(Icon::new(IconName::Warning).small()),
)
.child(
div()
@@ -1108,9 +1102,12 @@ impl ChatPanel {
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
text::rendered_text(
rendered_text(
&message.content,
&message.mentions,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
@@ -1392,83 +1389,34 @@ impl ChatPanel {
public_key: &PublicKey,
cx: &Context<Self>,
) -> impl IntoElement {
h_flex()
.p_0p5()
.gap_1()
.invisible()
.absolute()
.right_4()
.top_neg_2()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.children({
let mut items = vec![];
let mut actions = MessageActions::new();
for emoji in COMPACT_REACTION_EMOJIS {
items.push(
Button::new(*emoji)
.label(*emoji)
.tooltip(*emoji)
.small()
.ghost()
.on_click({
let emoji = *emoji;
let id = *id;
cx.listener(move |this, _event, window, cx| {
this.send_reaction(emoji, &id, window, cx);
})
}),
);
}
for emoji in COMPACT_REACTION_EMOJIS {
let id = *id;
actions = actions.reaction(
emoji,
cx.listener(move |this, _event, window, cx| {
this.send_reaction(emoji, &id, window, cx);
}),
);
}
items
let reply_id = *id;
let copy_id = *id;
let dropdown_id = *id;
let public_key = *public_key;
actions
.reply(cx.listener(move |this, _event, _window, cx| {
this.reply_to(&reply_id, cx);
}))
.copy(cx.listener(move |this, _event, _window, cx| {
this.copy_message(&copy_id, cx);
}))
.dropdown(move |menu, _window, _cx| {
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
})
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
Button::new("reply")
.icon(IconName::Reply)
.tooltip("Reply")
.small()
.ghost()
.on_click({
let id = id.to_owned();
cx.listener(move |this, _event, _window, cx| {
this.reply_to(&id, cx);
})
}),
)
.child(
Button::new("copy")
.icon(IconName::Copy)
.tooltip("Copy")
.small()
.ghost()
.on_click({
let id = id.to_owned();
cx.listener(move |this, _event, _window, cx| {
this.copy_message(&id, cx);
})
}),
)
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
Button::new("advance")
.icon(IconName::Ellipsis)
.small()
.ghost()
.dropdown_menu({
let public_key = *public_key;
let id = *id;
move |this, _window, _cx| {
this.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(id)))
}
}),
)
.group_hover("", |this| this.visible())
}
fn render_attachment(&self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
@@ -1494,7 +1442,7 @@ impl ChatPanel {
.justify_center()
.rounded_full()
.bg(red())
.child(Icon::new(IconName::X).size_2().text_color(white())),
.child(Icon::new(IconName::Close).size_2().text_color(white())),
)
.on_click({
let url = url.clone();
@@ -1695,7 +1643,7 @@ impl ChatPanel {
)
.child(
Button::new("remove-reply")
.icon(IconName::X)
.icon(IconName::Close)
.xsmall()
.ghost()
.on_click({
@@ -1746,7 +1694,6 @@ impl ChatPanel {
.icon(IconName::Settings2)
.tooltip("Configuration")
.ghost()
.large()
.dropdown_menu(move |this, _window, _cx| {
let auto = matches!(signer_kind, SignerKind::Auto);
let encryption = matches!(signer_kind, SignerKind::Encryption);
@@ -1792,9 +1739,8 @@ impl ChatPanel {
.unwrap_or_default();
Button::new("emoji")
.icon(IconName::Sticker)
.icon(IconName::Emoji)
.ghost()
.large()
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
@@ -1840,7 +1786,7 @@ impl Panel for ChatPanel {
Some(vec![
Button::new("subject")
.icon(IconName::TextCursorInput)
.icon(IconName::Input)
.tooltip("Change subject")
.small()
.ghost()
@@ -1885,7 +1831,7 @@ impl Render for ChatPanel {
.child(Input::new(&self.subject_input).small())
.child(
Button::new("change")
.icon(IconName::CircleCheck)
.icon(IconName::CheckCircle)
.label("Change")
.secondary()
.disabled(self.uploading)
@@ -1950,7 +1896,6 @@ impl Render for ChatPanel {
.loading(self.uploading)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.upload(window, cx);
})),
@@ -1964,10 +1909,9 @@ impl Render for ChatPanel {
.child(self.render_config_menu(window, cx))
.child(
Button::new("send")
.icon(IconName::SendHorizontal)
.icon(IconName::PaperPlaneFill)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.send_text_message(window, cx);
})),
-555
View File
@@ -1,555 +0,0 @@
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;
pub use ui::Avatar;
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(24.))),
}
} else {
this.child(div().flex_shrink_0().w(px(24.)))
}
})
.child(
v_flex()
.flex_1()
.w_full()
.min_w_0()
.flex_initial()
.overflow_hidden()
.when(show_author, |this| {
this.child(
h_flex()
.gap_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
.when_some(author, |this, author| {
this.child(div().font_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().list_hover))
.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_xs()
.refine_style(&style)
.child(div().font_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(cx.theme().radius_lg),
)
.into_any_element();
}
div()
.w_full()
.flex_1()
.flex()
.flex_row()
.flex_wrap()
.gap_2()
.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(cx.theme().radius_lg),
)
}))
.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_2()
.py_0p5()
.px_1()
.rounded(cx.theme().radius)
.text_xs()
.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_2()
.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_sm()
.font_semibold()
.text_color(cx.theme().foreground)
.child(title),
)
})
.when_some(message, |this, message| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(message),
)
}),
)
}
}
-179
View File
@@ -1,179 +0,0 @@
use std::ops::Range;
use chat::{Mention, nevent_refs};
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;
/// 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!(
"![{}](<{url}>)",
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,
mentions: &[Mention],
emojis: &[(String, Url)],
persons: &Entity<Directory>,
cx: &App,
) -> RenderedText {
let mut replacements = mentions
.iter()
.map(|mention| {
InlineReplacement::new(
mention.range.clone(),
format!(
"@{}",
persons.read(cx).person(&mention.public_key, cx).name()
),
)
})
.collect::<Vec<_>>();
replacements.extend(emoji_replacements(content, emojis));
replacements.extend(
nevent_refs(content)
.into_iter()
.map(|reference| InlineReplacement::new(reference.range, reference.label)),
);
replacements.sort_by_key(|replacement| replacement.range.start);
RenderedText::new(content, &replacements)
}
fn emoji_replacements(content: &str, emojis: &[(String, Url)]) -> Vec<InlineReplacement> {
let mut replacements = Vec::new();
for (shortcode, url) in emojis {
let pattern = format!(":{shortcode}:");
for (start, _) in content.match_indices(&pattern) {
let range = start..start + pattern.len();
// Skip matches glued to word characters, like the `30` in `12:30:45`
if content[..start].ends_with(char::is_alphanumeric)
|| content[range.end..].starts_with(char::is_alphanumeric)
{
continue;
}
replacements.push(InlineReplacement::image(
range,
shortcode.clone(),
url.to_string(),
));
}
}
replacements
}