feat: add support for custom emojis (NIP-30) (#63)
Reviewed-on: #63
This commit was merged in pull request #63.
This commit is contained in:
@@ -6,7 +6,7 @@ use settings::SignerKind;
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub enum Command {
|
||||
Insert(&'static str),
|
||||
Insert(String),
|
||||
ChangeSubject(String),
|
||||
ChangeSigner(SignerKind),
|
||||
ToggleBackup,
|
||||
|
||||
+40
-16
@@ -5,14 +5,15 @@ use std::sync::{Arc, LazyLock, RwLock};
|
||||
pub use actions::*;
|
||||
use anyhow::Error;
|
||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport};
|
||||
use common::TimestampExt;
|
||||
use common::{TimestampExt, emojis_from_tags};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement,
|
||||
Styled, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task,
|
||||
WeakEntity, Window, div, img, list, px, red, relative, retain_all, svg, white,
|
||||
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 itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -52,6 +53,10 @@ const PRIVATE_NOTICE: &str =
|
||||
static EMOJI_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||
|
||||
/// Regex matching a single custom emoji reference, like `:soapbox:`
|
||||
static CUSTOM_EMOJI_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^:[A-Za-z0-9_-]+:$").unwrap());
|
||||
|
||||
mod actions;
|
||||
mod file;
|
||||
pub mod text;
|
||||
@@ -78,7 +83,7 @@ pub struct ChatPanel {
|
||||
message_index: HashMap<EventId, usize>,
|
||||
|
||||
/// All reactions
|
||||
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
||||
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
|
||||
|
||||
/// Mapping message ids to their rendered texts
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
@@ -330,7 +335,7 @@ impl ChatPanel {
|
||||
// If replying to exactly one message with only a valid emoji,
|
||||
// send as a reaction instead of a text message
|
||||
if replies.len() == 1
|
||||
&& EMOJI_RE.is_match(&content)
|
||||
&& (EMOJI_RE.is_match(&content) || CUSTOM_EMOJI_RE.is_match(&content))
|
||||
&& self.attachments.read(cx).is_empty()
|
||||
&& files.is_empty()
|
||||
{
|
||||
@@ -535,11 +540,16 @@ impl ChatPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
let emojis = emojis_from_tags(&event.tags);
|
||||
let image = Message::reaction_image(&event.content, &emojis)
|
||||
.map(|url| SharedUri::from(url.to_string()));
|
||||
|
||||
for id in event.tags.event_ids() {
|
||||
self.reactions
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.push((SharedString::from(&event.content), event.pubkey));
|
||||
self.reactions.entry(id).or_default().push((
|
||||
SharedString::from(&event.content),
|
||||
image.clone(),
|
||||
event.pubkey,
|
||||
));
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
@@ -563,7 +573,7 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
/// Get a reaction by its target ID (returns reference, no allocation)
|
||||
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
|
||||
fn reaction(&self, id: &EventId) -> &[(SharedString, Option<SharedUri>, PublicKey)] {
|
||||
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
@@ -1081,7 +1091,13 @@ impl ChatPanel {
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| {
|
||||
text::rendered_text(&message.content, &message.mentions, &persons, cx)
|
||||
text::rendered_text(
|
||||
&message.content,
|
||||
&message.mentions,
|
||||
&message.emojis,
|
||||
&persons,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
|
||||
@@ -1741,15 +1757,23 @@ impl ChatPanel {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_emoji_menu(&self, _window: &Window, _cx: &Context<Self>) -> impl IntoElement {
|
||||
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let emojis = PersonRegistry::current_emojis(cx);
|
||||
|
||||
Button::new("emoji")
|
||||
.icon(IconName::Emoji)
|
||||
.ghost()
|
||||
.large()
|
||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||
let menu = this.horizontal();
|
||||
REACTION_EMOJIS.iter().fold(menu, |this, emoji| {
|
||||
this.menu(*emoji, Box::new(Command::Insert(emoji)))
|
||||
let menu = REACTION_EMOJIS
|
||||
.iter()
|
||||
.fold(this.horizontal(), |this, emoji| {
|
||||
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
|
||||
});
|
||||
|
||||
emojis.iter().fold(menu, |this, (shortcode, _)| {
|
||||
let label = format!(":{shortcode}:");
|
||||
this.menu(label.clone(), Box::new(Command::Insert(label)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
use chat::Mention;
|
||||
use gpui::{App, Entity};
|
||||
use nostr_sdk::prelude::Url;
|
||||
use person::PersonRegistry;
|
||||
use ui::markdown::InlineReplacement;
|
||||
pub use ui::markdown::RenderedText;
|
||||
|
||||
/// Render message `content`, replacing mentions with their display names.
|
||||
pub fn rendered_text(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
emojis: &[(String, Url)],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
cx: &App,
|
||||
) -> RenderedText {
|
||||
let replacements = mentions
|
||||
let mut replacements = mentions
|
||||
.iter()
|
||||
.map(|mention| {
|
||||
InlineReplacement::new(
|
||||
@@ -21,5 +22,62 @@ pub fn rendered_text(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
replacements.extend(emoji_replacements(content, emojis));
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn emoji(shortcode: &str, url: &str) -> (String, Url) {
|
||||
(String::from(shortcode), Url::parse(url).expect("valid url"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_shortcodes_become_image_replacements() {
|
||||
let emojis = vec![emoji("soapbox", "https://example.com/soapbox.png")];
|
||||
|
||||
let replacements = emoji_replacements("hi :soapbox: and :other:", &emojis);
|
||||
|
||||
assert_eq!(replacements.len(), 1);
|
||||
assert_eq!(replacements[0].range, 3..12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_glued_to_word_characters_are_skipped() {
|
||||
let emojis = vec![emoji("30", "https://example.com/30.png")];
|
||||
|
||||
assert!(emoji_replacements("at 12:30:45 exactly", &emojis).is_empty());
|
||||
assert!(emoji_replacements("x:30: and :30:y", &emojis).is_empty());
|
||||
assert_eq!(emoji_replacements("(:30:)", &emojis).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user