Support NIP-30 custom emoji in messages and reactions
This commit is contained in:
@@ -22,6 +22,8 @@ pub struct Message {
|
|||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
/// List of mentioned public keys in the message
|
/// List of mentioned public keys in the message
|
||||||
pub mentions: Vec<Mention>,
|
pub mentions: Vec<Mention>,
|
||||||
|
/// Custom emoji (NIP-30) declared by this message's `emoji` tags
|
||||||
|
pub emojis: Vec<(String, Url)>,
|
||||||
/// List of event of the message this message is a reply to
|
/// List of event of the message this message is a reply to
|
||||||
pub replies_to: Vec<EventId>,
|
pub replies_to: Vec<EventId>,
|
||||||
/// Encrypted file attachment
|
/// Encrypted file attachment
|
||||||
@@ -85,6 +87,7 @@ fn from_parts(
|
|||||||
let has_file = file.is_some();
|
let has_file = file.is_some();
|
||||||
|
|
||||||
let replies_to = extract_reply_ids(tags);
|
let replies_to = extract_reply_ids(tags);
|
||||||
|
let emojis = Message::emojis_from_tags(tags);
|
||||||
|
|
||||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||||
let mentions = if has_file {
|
let mentions = if has_file {
|
||||||
@@ -106,6 +109,7 @@ fn from_parts(
|
|||||||
media,
|
media,
|
||||||
created_at,
|
created_at,
|
||||||
mentions,
|
mentions,
|
||||||
|
emojis,
|
||||||
replies_to,
|
replies_to,
|
||||||
file,
|
file,
|
||||||
}
|
}
|
||||||
@@ -146,6 +150,35 @@ impl Message {
|
|||||||
|
|
||||||
self.content.clone().into()
|
self.content.clone().into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
|
||||||
|
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
|
||||||
|
let mut emojis: Vec<(String, Url)> = Vec::new();
|
||||||
|
|
||||||
|
for tag in tags.iter() {
|
||||||
|
let Ok(Nip30Tag::Emoji {
|
||||||
|
shortcode,
|
||||||
|
image_url,
|
||||||
|
..
|
||||||
|
}) = Nip30Tag::try_from(tag)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !emojis.iter().any(|(existing, _)| *existing == shortcode) {
|
||||||
|
emojis.push((shortcode, image_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emojis
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image URL of a `:shortcode:` reaction, if the shortcode is declared
|
||||||
|
pub fn reaction_image(content: &str, emojis: &[(String, Url)]) -> Option<Url> {
|
||||||
|
let shortcode = content.strip_prefix(':')?.strip_suffix(':')?;
|
||||||
|
let (_, url) = emojis.iter().find(|(name, _)| name == shortcode)?;
|
||||||
|
Some(url.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// New message.
|
/// New message.
|
||||||
@@ -226,3 +259,68 @@ fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
|||||||
|
|
||||||
replies_to
|
replies_to
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rumor(kind: Kind, content: &str, tags: Tags) -> UnsignedEvent {
|
||||||
|
let sender = Keys::generate();
|
||||||
|
|
||||||
|
let mut rumor = EventBuilder::new(kind, content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalize_unsigned(sender.public_key());
|
||||||
|
rumor.ensure_id();
|
||||||
|
rumor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emoji_tag(shortcode: &str, url: &str) -> Tag {
|
||||||
|
Tag::parse(vec!["emoji", shortcode, url]).expect("valid emoji tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emojis_from_tags_parses_and_deduplicates() {
|
||||||
|
let tags = Tags::from_list(vec![
|
||||||
|
emoji_tag("soapbox", "https://example.com/soapbox.png"),
|
||||||
|
emoji_tag("soapbox", "https://example.com/other.png"),
|
||||||
|
emoji_tag("bad shortcode", "https://example.com/bad.png"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let emojis = Message::emojis_from_tags(&tags);
|
||||||
|
|
||||||
|
assert_eq!(emojis.len(), 1);
|
||||||
|
assert_eq!(emojis[0].0, "soapbox");
|
||||||
|
assert_eq!(
|
||||||
|
emojis[0].1,
|
||||||
|
Url::parse("https://example.com/soapbox.png").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_parses_emoji_tags() {
|
||||||
|
let tags = Tags::from_list(vec![emoji_tag(
|
||||||
|
"soapbox",
|
||||||
|
"https://example.com/soapbox.png",
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let message = Message::from(&rumor(Kind::PrivateDirectMessage, "hi :soapbox:", tags));
|
||||||
|
|
||||||
|
assert_eq!(message.emojis.len(), 1);
|
||||||
|
assert_eq!(message.emojis[0].0, "soapbox");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reaction_image_matches_declared_shortcode() {
|
||||||
|
let emojis = vec![(
|
||||||
|
String::from("soapbox"),
|
||||||
|
Url::parse("https://example.com/soapbox.png").unwrap(),
|
||||||
|
)];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Message::reaction_image(":soapbox:", &emojis),
|
||||||
|
Some(Url::parse("https://example.com/soapbox.png").unwrap())
|
||||||
|
);
|
||||||
|
assert_eq!(Message::reaction_image("😂", &emojis), None);
|
||||||
|
assert_eq!(Message::reaction_image(":unknown:", &emojis), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+22
-10
@@ -10,9 +10,10 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||||
Styled, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
||||||
WeakEntity, Window, div, img, list, px, red, relative, retain_all, svg, white,
|
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
||||||
|
retain_all, svg, white,
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -78,7 +79,7 @@ pub struct ChatPanel {
|
|||||||
message_index: HashMap<EventId, usize>,
|
message_index: HashMap<EventId, usize>,
|
||||||
|
|
||||||
/// All reactions
|
/// All reactions
|
||||||
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
|
||||||
|
|
||||||
/// Mapping message ids to their rendered texts
|
/// Mapping message ids to their rendered texts
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
@@ -535,11 +536,16 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let emojis = Message::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() {
|
for id in event.tags.event_ids() {
|
||||||
self.reactions
|
self.reactions.entry(id).or_default().push((
|
||||||
.entry(id)
|
SharedString::from(&event.content),
|
||||||
.or_default()
|
image.clone(),
|
||||||
.push((SharedString::from(&event.content), event.pubkey));
|
event.pubkey,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -563,7 +569,7 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reaction by its target ID (returns reference, no allocation)
|
/// 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(&[])
|
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1081,7 +1087,13 @@ impl ChatPanel {
|
|||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| {
|
.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);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
use chat::Mention;
|
use chat::Mention;
|
||||||
use gpui::{App, Entity};
|
use gpui::{App, Entity};
|
||||||
|
use nostr_sdk::prelude::Url;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use ui::markdown::InlineReplacement;
|
use ui::markdown::InlineReplacement;
|
||||||
pub use ui::markdown::RenderedText;
|
pub use ui::markdown::RenderedText;
|
||||||
|
|
||||||
/// Render message `content`, replacing mentions with their display names.
|
|
||||||
pub fn rendered_text(
|
pub fn rendered_text(
|
||||||
content: &str,
|
content: &str,
|
||||||
mentions: &[Mention],
|
mentions: &[Mention],
|
||||||
|
emojis: &[(String, Url)],
|
||||||
persons: &Entity<PersonRegistry>,
|
persons: &Entity<PersonRegistry>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
let replacements = mentions
|
let mut replacements = mentions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|mention| {
|
.map(|mention| {
|
||||||
InlineReplacement::new(
|
InlineReplacement::new(
|
||||||
@@ -21,5 +22,62 @@ pub fn rendered_text(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
replacements.extend(emoji_replacements(content, emojis));
|
||||||
|
replacements.sort_by_key(|replacement| replacement.range.start);
|
||||||
|
|
||||||
RenderedText::new(content, &replacements)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, anyhow, bail};
|
||||||
use chat::Message;
|
use chat::Message;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
@@ -31,6 +31,7 @@ enum Absorbed {
|
|||||||
pub struct Reaction {
|
pub struct Reaction {
|
||||||
pub id: EventId,
|
pub id: EventId,
|
||||||
pub emoji: String,
|
pub emoji: String,
|
||||||
|
pub image: Option<SharedUri>,
|
||||||
pub author: PublicKey,
|
pub author: PublicKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,9 +757,14 @@ impl Group {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let emojis = Message::emojis_from_tags(&event.tags);
|
||||||
|
let image = Message::reaction_image(&event.content, &emojis)
|
||||||
|
.map(|url| SharedUri::from(url.to_string()));
|
||||||
|
|
||||||
self.reactions.entry(target).or_default().push(Reaction {
|
self.reactions.entry(target).or_default().push(Reaction {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
emoji: event.content.clone(),
|
emoji: event.content.clone(),
|
||||||
|
image,
|
||||||
author: event.pubkey,
|
author: event.pubkey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -886,7 +886,15 @@ impl GroupPanel {
|
|||||||
let content = self
|
let content = self
|
||||||
.rendered_texts_by_id
|
.rendered_texts_by_id
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| rendered_text(&message.content, &message.mentions, &persons, cx))
|
.or_insert_with(|| {
|
||||||
|
rendered_text(
|
||||||
|
&message.content,
|
||||||
|
&message.mentions,
|
||||||
|
&message.emojis,
|
||||||
|
&persons,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
.element(ix.into(), window, cx);
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
let admin = nostr
|
let admin = nostr
|
||||||
|
|||||||
@@ -43,9 +43,13 @@ pub(crate) fn render(
|
|||||||
let author = persons.read(cx).get(&message.author, cx);
|
let author = persons.read(cx).get(&message.author, cx);
|
||||||
|
|
||||||
let reaction_groups = group_reactions(
|
let reaction_groups = group_reactions(
|
||||||
reactions
|
reactions.iter().map(|reaction| {
|
||||||
.iter()
|
(
|
||||||
.map(|reaction| (SharedString::from(reaction.emoji.as_str()), reaction.author)),
|
SharedString::from(reaction.emoji.as_str()),
|
||||||
|
reaction.image.clone(),
|
||||||
|
reaction.author,
|
||||||
|
)
|
||||||
|
}),
|
||||||
nostr.read(cx).current_user(),
|
nostr.read(cx).current_user(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, StyleRefinement,
|
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, SharedUri,
|
||||||
Styled as _, Window,
|
StyleRefinement, Styled as _, Window,
|
||||||
};
|
};
|
||||||
use gpui_base::{TextView, TextViewStyle};
|
use gpui_base::{TextView, TextViewStyle};
|
||||||
use theme::ActiveTheme as _;
|
use theme::ActiveTheme as _;
|
||||||
@@ -12,6 +12,8 @@ use theme::ActiveTheme as _;
|
|||||||
pub struct InlineReplacement {
|
pub struct InlineReplacement {
|
||||||
pub range: Range<usize>,
|
pub range: Range<usize>,
|
||||||
pub text: SharedString,
|
pub text: SharedString,
|
||||||
|
/// When set, the span renders as this image (NIP-30 custom emoji)
|
||||||
|
pub image: Option<SharedUri>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InlineReplacement {
|
impl InlineReplacement {
|
||||||
@@ -19,6 +21,19 @@ impl InlineReplacement {
|
|||||||
Self {
|
Self {
|
||||||
range,
|
range,
|
||||||
text: text.into(),
|
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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,11 +61,17 @@ impl RenderedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
text.push_str(&content[last..range.start]);
|
text.push_str(&content[last..range.start]);
|
||||||
text.push_str(&format!(
|
match &replacement.image {
|
||||||
"[{}](<{}>)",
|
Some(url) => text.push_str(&format!(
|
||||||
escape_link_text(&replacement.text),
|
"",
|
||||||
&content[range.start..range.end]
|
escape_link_text(&replacement.text)
|
||||||
));
|
)),
|
||||||
|
None => text.push_str(&format!(
|
||||||
|
"[{}](<{}>)",
|
||||||
|
escape_link_text(&replacement.text),
|
||||||
|
&content[range.start..range.end]
|
||||||
|
)),
|
||||||
|
}
|
||||||
last = range.end;
|
last = range.end;
|
||||||
}
|
}
|
||||||
text.push_str(&content[last..]);
|
text.push_str(&content[last..]);
|
||||||
|
|||||||
@@ -370,26 +370,36 @@ impl RenderOnce for MessageMedia {
|
|||||||
/// Reactions of one emoji with the count of authors and whether the current user reacted.
|
/// Reactions of one emoji with the count of authors and whether the current user reacted.
|
||||||
pub struct ReactionGroup {
|
pub struct ReactionGroup {
|
||||||
pub emoji: SharedString,
|
pub emoji: SharedString,
|
||||||
|
/// Image of a custom emoji (NIP-30) reaction
|
||||||
|
pub image: Option<SharedUri>,
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
pub mine: bool,
|
pub mine: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Group (emoji, author) pairs by emoji, sorted by emoji, marking groups containing `me`.
|
/// Group (emoji, image, author) triples by emoji, sorted by emoji.
|
||||||
pub fn group_reactions<A: PartialEq>(
|
pub fn group_reactions<A: PartialEq>(
|
||||||
reactions: impl IntoIterator<Item = (SharedString, A)>,
|
reactions: impl IntoIterator<Item = (SharedString, Option<SharedUri>, A)>,
|
||||||
me: Option<A>,
|
me: Option<A>,
|
||||||
) -> Vec<ReactionGroup> {
|
) -> Vec<ReactionGroup> {
|
||||||
let mut groups: BTreeMap<SharedString, (usize, bool)> = BTreeMap::new();
|
let mut groups: BTreeMap<SharedString, (usize, bool, Option<SharedUri>)> = BTreeMap::new();
|
||||||
|
|
||||||
for (emoji, author) in reactions {
|
for (emoji, image, author) in reactions {
|
||||||
let group = groups.entry(emoji).or_default();
|
let group = groups.entry(emoji).or_default();
|
||||||
group.0 += 1;
|
group.0 += 1;
|
||||||
group.1 |= me.as_ref() == Some(&author);
|
group.1 |= me.as_ref() == Some(&author);
|
||||||
|
if group.2.is_none() {
|
||||||
|
group.2 = image;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups
|
groups
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(emoji, (count, mine))| ReactionGroup { emoji, count, mine })
|
.map(|(emoji, (count, mine, image))| ReactionGroup {
|
||||||
|
emoji,
|
||||||
|
image,
|
||||||
|
count,
|
||||||
|
mine,
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +448,10 @@ impl RenderOnce for ReactionBar {
|
|||||||
.border_color(cx.theme().element_active)
|
.border_color(cx.theme().element_active)
|
||||||
})
|
})
|
||||||
.when(!group.mine, |this| this.border_color(cx.theme().border))
|
.when(!group.mine, |this| this.border_color(cx.theme().border))
|
||||||
.child(group.emoji)
|
.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()))
|
.child(SharedString::from(group.count.to_string()))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user