diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 9ab7ed2..4a52698 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -59,6 +59,9 @@ pub struct ChatPanel { /// All messages (sorted by created_at) messages: Vec, + /// All reactions + reactions: BTreeMap>, + /// Mapping message ids to their rendered texts rendered_texts_by_id: BTreeMap, @@ -163,6 +166,7 @@ impl ChatPanel { focus_handle: cx.focus_handle(), id, messages, + reactions: BTreeMap::new(), room, list_state, input, @@ -262,7 +266,11 @@ impl ChatPanel { move |this, _room, event, window, cx| { match event { RoomEvent::Incoming(message) => { - this.insert_message(message, false, cx); + if message.rumor.kind == Kind::Reaction { + this.insert_reaction(&message.rumor, cx); + } else { + this.insert_message(message, false, cx); + } } RoomEvent::Reload => { // Defer to avoid re-entrant read on Room while @@ -384,8 +392,10 @@ impl ChatPanel { let Some(room_entity) = room.upgrade() else { return; }; + + // Create rumor and send task let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| { - let rumor = room.rumor(content.clone(), replies, reaction, cx)?; + let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?; let send_task = room.send(rumor.clone(), cx)?; Some((rumor, send_task)) }) { @@ -399,9 +409,15 @@ impl ChatPanel { let id = rumor.id.expect("rumor must have an id"); // Insert optimistic message and clear input - self.insert_message(&rumor, true, cx); + if rumor.kind != Kind::Reaction { + self.insert_message(&rumor, true, cx); + self.clear(window, cx); + } else { + self.insert_reaction(&rumor, cx); + } + + // Update reports self.insert_reports(id, vec![], cx); - self.clear(window, cx); // Spawn a single task to await the send and update reports self.tasks.push(cx.spawn_in(window, async move |this, cx| { @@ -472,11 +488,31 @@ impl ChatPanel { /// Convert and insert a vector of nostr events into the chat panel fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context) { for event in events.iter() { + if event.kind == Kind::Reaction { + self.insert_reaction(event, cx); + continue; + } // Bulk inserting messages, so no need to scroll to the latest message self.insert_message(event, false, cx); } } + /// Insert a reaction into the chat panel + fn insert_reaction(&mut self, event: &UnsignedEvent, cx: &mut Context) { + if event.kind != Kind::Reaction { + return; + } + + for id in event.tags.event_ids() { + self.reactions + .entry(id) + .or_default() + .push((SharedString::from(&event.content), event.pubkey)); + } + + cx.notify(); + } + /// Check if a message has any reports fn has_reports(&self, id: &EventId, _cx: &App) -> bool { self.reports_by_id.read().unwrap().get(id).is_some() @@ -491,6 +527,16 @@ impl ChatPanel { self.messages.iter().find(|msg| &msg.id == id) } + /// Get a reaction by its target ID + fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> { + self.reactions.get(id).cloned().unwrap_or_default() + } + + /// Check if a message has any reactions + fn has_reaction(&self, id: &EventId) -> bool { + self.reactions.contains_key(id) + } + /// Scroll to a message by its ID fn scroll_to(&self, id: &EventId) { if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) { @@ -865,6 +911,7 @@ impl ChatPanel { let replies = message.replies_to.as_slice(); let has_replies = !replies.is_empty(); + let has_reactions = self.has_reaction(&id); let has_reports = self.has_reports(&id, cx); // Hide avatar setting @@ -910,12 +957,7 @@ impl ChatPanel { .gap_2() .text_sm() .text_color(cx.theme().text_placeholder) - .child( - div() - .font_semibold() - .text_color(cx.theme().text) - .child(author.name()), - ) + .child(div().font_semibold().child(author.name())) .child(message.created_at.to_human_time()) .when(has_reports, |this| { this.child(self.render_sent_reports(&id, cx)) @@ -926,7 +968,10 @@ impl ChatPanel { this.children(self.render_message_replies(replies, cx)) }) .child(rendered_text) - .child(self.render_media(&message.media, cx)), + .child(self.render_media(&message.media, cx)) + .when(has_reactions, |this| { + this.child(self.render_reactions(&id, cx)) + }), ), ) .child( @@ -1023,7 +1068,7 @@ impl ChatPanel { .border_l_2() .border_color(cx.theme().element_active) .text_sm() - .child(div().child(author.name())) + .child(div().font_semibold().child(author.name())) .child( div() .w_full() @@ -1044,6 +1089,43 @@ impl ChatPanel { items } + fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement { + let current_user = NostrRegistry::global(cx).read(cx).current_user(); + let reactions = self.reaction(id); + + // Group reactions by emoji and collect authors for each + let mut grouped: BTreeMap> = BTreeMap::new(); + for (emoji, author) in &reactions { + grouped.entry(emoji.clone()).or_default().push(*author); + } + + h_flex() + .mt_2() + .gap_1() + .children(grouped.into_iter().map(|(emoji, authors)| { + let count = authors.len(); + let has_reacted = current_user + .map(|pk| authors.contains(&pk)) + .unwrap_or(false); + + h_flex() + .gap_2() + .py_0p5() + .px_1() + .rounded(cx.theme().radius) + .text_xs() + .border_1() + .when(has_reacted, |this| { + this.text_color(cx.theme().secondary_foreground) + .bg(cx.theme().secondary_background) + .border_color(cx.theme().secondary_active) + }) + .when(!has_reacted, |this| this.border_color(cx.theme().border)) + .child(emoji) + .child(SharedString::from(count.to_string())) + })) + } + fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement { let reports = self.sent_reports(id, cx);