Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/domain/entities/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
}
}
32 changes: 30 additions & 2 deletions src/presentation/ui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, ..
Expand Down
30 changes: 28 additions & 2 deletions src/presentation/ui/chat_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,18 +547,20 @@ 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,
&service,
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();

Expand Down Expand Up @@ -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);
}
Expand Down
Loading