From b939cad2e7b8e76e8e68f2884e0079dc5170e572 Mon Sep 17 00:00:00 2001 From: guitaripod Date: Tue, 21 Apr 2026 22:14:29 +0300 Subject: [PATCH] feat: display message reactions Reactions are parsed from REST history and gateway events are dispatched, but nothing was rendered. Wire through display and mutation so the UI reflects reaction state in real time. Message domain grows add/remove/clear helpers, used both by the REST seed path and by the three gateway events (MESSAGE_REACTION_ADD, MESSAGE_REACTION_REMOVE, MESSAGE_REACTION_REMOVE_ALL). Chips render as `{emoji} {count}` below content/attachments/embeds, wrapping across lines when they exceed message width; the current user's own reactions use the theme accent + bold, others are dimmed. --- src/domain/entities/message.rs | 133 ++++++++++ src/presentation/ui/app.rs | 32 ++- src/presentation/ui/chat_screen.rs | 30 ++- src/presentation/widgets/message_pane.rs | 298 ++++++++++++++++++++++- 4 files changed, 486 insertions(+), 7 deletions(-) diff --git a/src/domain/entities/message.rs b/src/domain/entities/message.rs index 42e2e15c1..2debd15e1 100644 --- a/src/domain/entities/message.rs +++ b/src/domain/entities/message.rs @@ -617,4 +617,137 @@ impl Message { pub fn can_be_edited_by(&self, user: &User) -> bool { self.author.id == user.id_str() } + + /// Increments the reaction count for the given emoji, or inserts a new entry. + /// When `is_me` is true, flags the reaction as authored by the current user. + pub fn add_reaction(&mut self, emoji: ReactionEmoji, is_me: bool) { + if let Some(existing) = self.reactions.iter_mut().find(|r| r.emoji == emoji) { + existing.count = existing.count.saturating_add(1); + if is_me { + existing.me = true; + } + } else { + self.reactions.push(Reaction { + count: 1, + me: is_me, + emoji, + }); + } + } + + /// Decrements the reaction count for the given emoji. Removes the entry when + /// it reaches zero. + pub fn remove_reaction(&mut self, emoji: &ReactionEmoji, is_me: bool) { + if let Some(pos) = self.reactions.iter().position(|r| &r.emoji == emoji) { + let r = &mut self.reactions[pos]; + r.count = r.count.saturating_sub(1); + if is_me { + r.me = false; + } + if r.count == 0 { + self.reactions.remove(pos); + } + } + } + + /// Removes all reactions from the message. + pub fn clear_reactions(&mut self) { + self.reactions.clear(); + } +} + +impl ReactionEmoji { + /// Returns a display string for the emoji: the unicode character for + /// standard emoji, or `:name:` for custom guild emoji. + #[must_use] + pub fn display(&self) -> String { + match (&self.id, &self.name) { + (Some(_), Some(name)) => format!(":{name}:"), + (Some(id), None) => format!(":{id}:"), + (None, Some(name)) => name.clone(), + (None, None) => "?".to_string(), + } + } +} + +#[cfg(test)] +mod reaction_tests { + use super::*; + use chrono::TimeZone; + + fn make_message() -> Message { + Message::new( + MessageId(1), + super::super::ChannelId(1), + MessageAuthor { + id: "1".into(), + username: "u".into(), + discriminator: "0".into(), + avatar: None, + bot: false, + global_name: None, + color: None, + }, + String::new(), + Local.timestamp_opt(0, 0).unwrap(), + MessageKind::Default, + ) + } + + fn unicode(name: &str) -> ReactionEmoji { + ReactionEmoji { + id: None, + name: Some(name.to_string()), + } + } + + #[test] + fn add_reaction_inserts_new_entry() { + let mut msg = make_message(); + msg.add_reaction(unicode("❤"), false); + assert_eq!(msg.reactions().len(), 1); + assert_eq!(msg.reactions()[0].count, 1); + assert!(!msg.reactions()[0].me); + } + + #[test] + fn add_reaction_increments_existing_and_sets_me() { + let mut msg = make_message(); + msg.add_reaction(unicode("❤"), false); + msg.add_reaction(unicode("❤"), true); + assert_eq!(msg.reactions().len(), 1); + assert_eq!(msg.reactions()[0].count, 2); + assert!(msg.reactions()[0].me); + } + + #[test] + fn remove_reaction_decrements_and_drops_at_zero() { + let mut msg = make_message(); + msg.add_reaction(unicode("👍"), true); + msg.add_reaction(unicode("👍"), false); + msg.remove_reaction(&unicode("👍"), true); + assert_eq!(msg.reactions()[0].count, 1); + assert!(!msg.reactions()[0].me); + msg.remove_reaction(&unicode("👍"), false); + assert!(msg.reactions().is_empty()); + } + + #[test] + fn clear_reactions_empties_list() { + let mut msg = make_message(); + msg.add_reaction(unicode("a"), true); + msg.add_reaction(unicode("b"), false); + msg.clear_reactions(); + assert!(msg.reactions().is_empty()); + } + + #[test] + fn display_formats_custom_and_unicode() { + assert_eq!(unicode("❤").display(), "❤"); + let custom = ReactionEmoji { + id: Some("123".into()), + name: Some("wave".into()), + }; + assert_eq!(custom.display(), ":wave:"); + } } diff --git a/src/presentation/ui/app.rs b/src/presentation/ui/app.rs index af6ceee09..e1a295993 100644 --- a/src/presentation/ui/app.rs +++ b/src/presentation/ui/app.rs @@ -1118,14 +1118,42 @@ impl App { debug!(user_id = %user_id, status = ?status, "Presence updated"); } DispatchEvent::MessageReactionAdd { - message_id, emoji, .. + user_id, + message_id, + emoji, + .. } => { debug!(message_id = %message_id, emoji = %emoji.display(), "Reaction added"); + let is_me = self.current_user_id.as_deref() == Some(user_id.as_str()); + let domain_emoji = crate::domain::entities::ReactionEmoji { + id: emoji.id, + name: emoji.name, + }; + if let CurrentScreen::Chat(ref mut state) = self.screen { + state.apply_reaction_add(message_id, domain_emoji, is_me); + } } DispatchEvent::MessageReactionRemove { - message_id, emoji, .. + user_id, + message_id, + emoji, + .. } => { debug!(message_id = %message_id, emoji = %emoji.display(), "Reaction removed"); + let is_me = self.current_user_id.as_deref() == Some(user_id.as_str()); + let domain_emoji = crate::domain::entities::ReactionEmoji { + id: emoji.id, + name: emoji.name, + }; + if let CurrentScreen::Chat(ref mut state) = self.screen { + state.apply_reaction_remove(message_id, &domain_emoji, is_me); + } + } + DispatchEvent::MessageReactionRemoveAll { message_id, .. } => { + debug!(message_id = %message_id, "All reactions removed"); + if let CurrentScreen::Chat(ref mut state) = self.screen { + state.apply_reaction_remove_all(message_id); + } } DispatchEvent::ChannelCreate { channel_id, name, .. diff --git a/src/presentation/ui/chat_screen.rs b/src/presentation/ui/chat_screen.rs index 2d67d756d..2fe85be65 100644 --- a/src/presentation/ui/chat_screen.rs +++ b/src/presentation/ui/chat_screen.rs @@ -547,6 +547,8 @@ fn render_message_pane(state: &mut ChatScreenState, area: Rect, buf: &mut Buffer let relationship_state = state.relationship_state.clone(); let hide_blocked_completely = state.hide_blocked_completely; + let style = MessagePaneStyle::from_theme(&state.theme); + let inner_width = area.width.saturating_sub(2); state.message_pane_data.update_layout( inner_width, @@ -554,11 +556,11 @@ fn render_message_pane(state: &mut ChatScreenState, area: Rect, buf: &mut Buffer state.theme.accent, state.message_pane_state.show_spoilers, image_preview, + style.reaction_me_style, + style.reaction_other_style, ); state.update_visible_image_protocols(inner_width); - - let style = MessagePaneStyle::from_theme(&state.theme); let current_user_id = state.user().id().to_string(); let (data, pane_state) = state.message_pane_parts_mut(); @@ -2066,6 +2068,30 @@ impl ChatScreenState { self.message_pane_data.remove_message(message_id); } + pub fn apply_reaction_add( + &mut self, + message_id: crate::domain::entities::MessageId, + emoji: crate::domain::entities::ReactionEmoji, + is_me: bool, + ) { + self.message_pane_data + .apply_reaction_add(message_id, emoji, is_me); + } + + pub fn apply_reaction_remove( + &mut self, + message_id: crate::domain::entities::MessageId, + emoji: &crate::domain::entities::ReactionEmoji, + is_me: bool, + ) { + self.message_pane_data + .apply_reaction_remove(message_id, emoji, is_me); + } + + pub fn apply_reaction_remove_all(&mut self, message_id: crate::domain::entities::MessageId) { + self.message_pane_data.apply_reaction_remove_all(message_id); + } + pub fn set_message_error(&mut self, error: String) { self.message_pane_data.set_error(error); } diff --git a/src/presentation/widgets/message_pane.rs b/src/presentation/widgets/message_pane.rs index 8692c5789..b81423566 100644 --- a/src/presentation/widgets/message_pane.rs +++ b/src/presentation/widgets/message_pane.rs @@ -7,7 +7,8 @@ use crate::application::services::markdown_parser::{ }; use crate::application::services::url_extractor::UrlExtractor; use crate::domain::entities::{ - ChannelId, Embed, ForumThread, ImageId, Message, MessageId, RelationshipState, USER_MENTION_RE, + ChannelId, Embed, ForumThread, ImageId, Message, MessageId, Reaction, RelationshipState, + USER_MENTION_RE, }; use crate::domain::keybinding::Action; @@ -83,6 +84,8 @@ pub struct UiMessage { pub rendered_embeds: Vec, /// Cached reply preview line pub reply_preview: Option>, + /// Cached reaction chip lines, one or more rows depending on wrap. + pub reaction_lines: Vec>, pub group: MessageGroup, pub rendered_generation: Option, } @@ -113,6 +116,7 @@ impl UiMessage { image_attachments, rendered_embeds: Vec::new(), reply_preview: None, + reaction_lines: Vec::new(), group: MessageGroup::Start, rendered_generation: None, } @@ -167,6 +171,81 @@ impl MentionResolver for HashMapResolver<'_> { } } +const REACTION_CHIP_GAP: usize = 2; + +fn reaction_chip_width(reaction: &Reaction) -> usize { + let emoji = reaction.emoji.display(); + let count = reaction.count.to_string(); + UnicodeWidthStr::width(emoji.as_str()) + .saturating_add(1) + .saturating_add(UnicodeWidthStr::width(count.as_str())) +} + +fn chip_spans(reaction: &Reaction, me_style: Style, other_style: Style) -> Vec> { + let style = if reaction.me { me_style } else { other_style }; + vec![Span::styled( + format!("{} {}", reaction.emoji.display(), reaction.count), + style, + )] +} + +/// Lays out reaction chips into one or more indented lines, wrapping when a +/// chip would overflow the given content width. Returns an empty vec when the +/// message has no reactions or when the available width is too small to render +/// even a single chip. +fn format_reaction_lines( + reactions: &[Reaction], + content_width: u16, + me_style: Style, + other_style: Style, +) -> Vec> { + if reactions.is_empty() || content_width == 0 { + return Vec::new(); + } + + let width = content_width as usize; + let mut lines: Vec> = Vec::new(); + let mut current: Vec> = Vec::new(); + let mut current_width = 0usize; + + for reaction in reactions { + let chip_w = reaction_chip_width(reaction); + if chip_w == 0 { + continue; + } + + let needs_gap = !current.is_empty(); + let projected = if needs_gap { + current_width.saturating_add(REACTION_CHIP_GAP).saturating_add(chip_w) + } else { + chip_w + }; + + if projected > width && !current.is_empty() { + lines.push(Line::from(std::mem::take(&mut current))); + current_width = 0; + } + + if !current.is_empty() { + current.push(Span::raw(" ".repeat(REACTION_CHIP_GAP))); + current_width = current_width.saturating_add(REACTION_CHIP_GAP); + } + + current.extend(chip_spans(reaction, me_style, other_style)); + current_width = current_width.saturating_add(chip_w); + } + + if !current.is_empty() { + lines.push(Line::from(current)); + } + + for line in &mut lines { + line.spans.insert(0, Span::raw(" ".repeat(CONTENT_INDENT))); + } + + lines +} + fn calculate_embed_layout( embed: &Embed, width: u16, @@ -414,6 +493,50 @@ impl MessagePaneData { self.is_dirty = true; } + /// Applies a reaction-add gateway event to the cached message, if present. + /// Returns true when a matching message was found and mutated. + pub fn apply_reaction_add( + &mut self, + message_id: MessageId, + emoji: crate::domain::entities::ReactionEmoji, + is_me: bool, + ) -> bool { + self.apply_reaction_change(message_id, |msg| msg.add_reaction(emoji, is_me)) + } + + /// Applies a reaction-remove gateway event to the cached message, if present. + pub fn apply_reaction_remove( + &mut self, + message_id: MessageId, + emoji: &crate::domain::entities::ReactionEmoji, + is_me: bool, + ) -> bool { + self.apply_reaction_change(message_id, |msg| msg.remove_reaction(emoji, is_me)) + } + + /// Clears every reaction from the cached message, if present. + pub fn apply_reaction_remove_all(&mut self, message_id: MessageId) -> bool { + self.apply_reaction_change(message_id, Message::clear_reactions) + } + + fn apply_reaction_change(&mut self, message_id: MessageId, mutate: F) -> bool + where + F: FnOnce(&mut Message), + { + let Some(ui_msg) = self + .messages + .iter_mut() + .find(|m| m.message.id() == message_id) + else { + return false; + }; + + mutate(Arc::make_mut(&mut ui_msg.message)); + ui_msg.rendered_generation = None; + self.is_dirty = true; + true + } + fn update_author<'a>(&mut self, id: impl Into>, name: String) { let id = id.into(); if self.authors.get(id.as_ref()) != Some(&name) { @@ -599,6 +722,7 @@ impl MessagePaneData { &mut self.messages } + #[allow(clippy::too_many_arguments)] pub fn update_layout( &mut self, width: u16, @@ -606,6 +730,8 @@ impl MessagePaneData { default_color: Color, show_spoilers: bool, image_preview: bool, + reaction_me_style: Style, + reaction_other_style: Style, ) { let full_invalidation = self.last_layout_width != Some(width) || self.last_show_spoilers != Some(show_spoilers) @@ -651,6 +777,8 @@ impl MessagePaneData { authors, self.use_display_name, current_generation, + reaction_me_style, + reaction_other_style, ); ui_msg.rendered_generation = Some(current_generation); } @@ -673,6 +801,8 @@ impl MessagePaneData { authors: &HashMap, use_display_name: bool, _authors_generation: usize, + reaction_me_style: Style, + reaction_other_style: Style, ) { let message = &ui_msg.message; @@ -716,6 +846,15 @@ impl MessagePaneData { } ui_msg.rendered_embeds = rendered_embeds; + let reaction_lines = format_reaction_lines( + message.reactions(), + content_width, + reaction_me_style, + reaction_other_style, + ); + height += u16::try_from(reaction_lines.len()).unwrap_or(0); + ui_msg.reaction_lines = reaction_lines; + if message.is_reply() { if let Some(referenced) = message.referenced() { let content = referenced.content(); @@ -1319,6 +1458,10 @@ pub struct MessagePaneStyle { pub scrollbar_track_style: Style, pub scrollbar_thumb_style: Style, pub blocked_style: Style, + /// Style for reactions authored by the current user. + pub reaction_me_style: Style, + /// Style for reactions authored by others. + pub reaction_other_style: Style, } impl MessagePaneStyle { @@ -1351,6 +1494,10 @@ impl MessagePaneStyle { blocked_style: Style::default() .fg(blocked_fg) .add_modifier(Modifier::ITALIC), + reaction_me_style: Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + reaction_other_style: theme.dimmed_style, ..Self::default() } } @@ -1393,6 +1540,10 @@ impl Default for MessagePaneStyle { blocked_style: Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), + reaction_me_style: Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + reaction_other_style: Style::default().fg(Color::DarkGray), } } } @@ -1532,6 +1683,14 @@ impl<'a> MessagePane<'a> { .height; } + let reaction_lines = format_reaction_lines( + message.reactions(), + content_width, + self.style.reaction_me_style, + self.style.reaction_other_style, + ); + height += u16::try_from(reaction_lines.len()).unwrap_or(0); + height } @@ -1628,6 +1787,8 @@ impl<'a> MessagePane<'a> { style.content_style.fg.unwrap_or(Color::White), state.show_spoilers, *image_preview, + style.reaction_me_style, + style.reaction_other_style, ); if let Some(error_msg) = &data.error_message @@ -2535,6 +2696,21 @@ fn render_ui_message( let height = render_embed(embed, current_msg_y, area, buf); current_msg_y += height; } + + for reaction_line in &ui_msg.reaction_lines { + if current_msg_y >= 0 && current_msg_y < i32::from(area.height) { + let para = Paragraph::new(reaction_line.clone()).style(base_style); + let line_area = Rect::new( + area.x, + area.y + .saturating_add(u16::try_from(current_msg_y).unwrap_or(0)), + area.width, + 1, + ); + para.render(line_area, buf); + } + current_msg_y += 1; + } } fn truncate_string(s: &str, max_len: usize) -> String { @@ -2756,10 +2932,118 @@ fn wrap_styled_text(text: Text<'static>, width: u16) -> Text<'static> { #[cfg(test)] mod tests { use super::*; - use crate::domain::entities::MessageAuthor; + use crate::domain::entities::{MessageAuthor, ReactionEmoji}; use chrono::Local; use test_case::test_case; + fn unicode_reaction(name: &str, count: u32, me: bool) -> Reaction { + Reaction { + count, + me, + emoji: ReactionEmoji { + id: None, + name: Some(name.to_string()), + }, + } + } + + #[test] + fn reaction_lines_empty_when_no_reactions() { + let lines = format_reaction_lines(&[], 80, Style::default(), Style::default()); + assert!(lines.is_empty()); + } + + #[test] + fn reaction_lines_wrap_when_exceeding_width() { + let reactions = vec![ + unicode_reaction("a", 1, false), + unicode_reaction("b", 1, false), + unicode_reaction("c", 1, false), + unicode_reaction("d", 1, false), + ]; + let narrow = format_reaction_lines(&reactions, 10, Style::default(), Style::default()); + assert!(narrow.len() > 1, "expected wrap into multiple lines"); + + let wide = format_reaction_lines(&reactions, 80, Style::default(), Style::default()); + assert_eq!(wide.len(), 1, "wide width should fit on one line"); + } + + /// Renders a set of reactions into a ratatui Buffer and returns it as a plain + /// string (one row per buffer line). Used by `reaction_lines_buffer_snapshot` + /// to produce a textual render of the feature for review. + fn render_reactions_to_text(reactions: &[Reaction], width: u16) -> String { + use ratatui::buffer::Buffer; + use ratatui::widgets::{Paragraph, Widget}; + + let me_style = Style::default().add_modifier(Modifier::BOLD); + let other = Style::default().fg(Color::DarkGray); + let lines = format_reaction_lines(reactions, width, me_style, other); + let height = u16::try_from(lines.len().max(1)).unwrap_or(1); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + + for (row, line) in lines.into_iter().enumerate() { + let area = Rect::new(0, u16::try_from(row).unwrap_or(0), width, 1); + Paragraph::new(line).render(area, &mut buf); + } + + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let cell = &buf[(x, y)]; + out.push_str(cell.symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn reaction_lines_buffer_snapshot() { + let reactions = vec![ + unicode_reaction("\u{2764}", 3, true), + unicode_reaction("\u{1F44D}", 1, false), + Reaction { + count: 2, + me: false, + emoji: ReactionEmoji { + id: Some("123".into()), + name: Some("wave".into()), + }, + }, + ]; + + let snapshot = render_reactions_to_text(&reactions, 40); + println!("--- reactions (width 40) ---\n{snapshot}--- end ---"); + assert!(snapshot.contains('\u{2764}')); + assert!(snapshot.contains('3')); + assert!(snapshot.contains(":wave:")); + assert!(snapshot.starts_with(" ")); + + let wrapped = render_reactions_to_text(&reactions, 16); + println!("--- reactions (width 16, wrapped) ---\n{wrapped}--- end ---"); + let line_count = wrapped.trim_end_matches('\n').split('\n').count(); + assert!(line_count >= 2, "expected wrap, got {line_count} line(s)"); + } + + #[test] + fn reaction_lines_apply_me_style_only_to_self_chip() { + let reactions = vec![ + unicode_reaction("x", 1, true), + unicode_reaction("y", 1, false), + ]; + let me = Style::default().add_modifier(Modifier::BOLD); + let other = Style::default().add_modifier(Modifier::ITALIC); + let lines = format_reaction_lines(&reactions, 80, me, other); + assert_eq!(lines.len(), 1); + let styles: Vec = lines[0] + .spans + .iter() + .map(|s| s.style.add_modifier) + .collect(); + assert!(styles.iter().any(|m| m.contains(Modifier::BOLD))); + assert!(styles.iter().any(|m| m.contains(Modifier::ITALIC))); + } + fn create_test_message(id: u64, content: &str) -> Message { let author = MessageAuthor { id: "1".to_string(), @@ -2840,7 +3124,15 @@ mod tests { data.set_messages(messages); let markdown = MarkdownRenderer::new(); - data.update_layout(100, &markdown, Color::Yellow, false, true); + data.update_layout( + 100, + &markdown, + Color::Yellow, + false, + true, + Style::default(), + Style::default(), + ); let mut state = MessagePaneState::new(); state.flags.is_following = true;