Compare commits
2
Commits
web-build
...
e01d2fbef3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e01d2fbef3 | ||
|
|
c3d677ca81 |
+14
-3
@@ -1,11 +1,11 @@
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use instant::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventExt;
|
use common::EventExt;
|
||||||
use device::DeviceRegistry;
|
use device::DeviceRegistry;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||||
|
use instant::Duration;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
@@ -419,12 +419,23 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Construct a rumor event for direct message
|
// Construct a rumor event for direct message
|
||||||
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
|
pub fn rumor<S, I>(
|
||||||
|
&self,
|
||||||
|
content: S,
|
||||||
|
replies: I,
|
||||||
|
reaction: bool,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<UnsignedEvent>
|
||||||
where
|
where
|
||||||
S: Into<String>,
|
S: Into<String>,
|
||||||
I: IntoIterator<Item = EventId>,
|
I: IntoIterator<Item = EventId>,
|
||||||
{
|
{
|
||||||
let kind = Kind::PrivateDirectMessage;
|
let kind = if reaction {
|
||||||
|
Kind::Reaction
|
||||||
|
} else {
|
||||||
|
Kind::PrivateDirectMessage
|
||||||
|
};
|
||||||
|
|
||||||
let content: String = content.into();
|
let content: String = content.into();
|
||||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||||
|
|
||||||
|
|||||||
+156
-29
@@ -35,6 +35,9 @@ use ui::{
|
|||||||
|
|
||||||
use crate::text::RenderedText;
|
use crate::text::RenderedText;
|
||||||
|
|
||||||
|
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||||
|
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
@@ -56,6 +59,9 @@ pub struct ChatPanel {
|
|||||||
/// All messages (sorted by created_at)
|
/// All messages (sorted by created_at)
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
|
|
||||||
|
/// All reactions
|
||||||
|
reactions: BTreeMap<EventId, Vec<(SharedString, 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>,
|
||||||
|
|
||||||
@@ -160,6 +166,7 @@ impl ChatPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
id,
|
id,
|
||||||
messages,
|
messages,
|
||||||
|
reactions: BTreeMap::new(),
|
||||||
room,
|
room,
|
||||||
list_state,
|
list_state,
|
||||||
input,
|
input,
|
||||||
@@ -259,8 +266,12 @@ impl ChatPanel {
|
|||||||
move |this, _room, event, window, cx| {
|
move |this, _room, event, window, cx| {
|
||||||
match event {
|
match event {
|
||||||
RoomEvent::Incoming(message) => {
|
RoomEvent::Incoming(message) => {
|
||||||
|
if message.rumor.kind == Kind::Reaction {
|
||||||
|
this.insert_reaction(&message.rumor, cx);
|
||||||
|
} else {
|
||||||
this.insert_message(message, false, cx);
|
this.insert_message(message, false, cx);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
RoomEvent::Reload => {
|
RoomEvent::Reload => {
|
||||||
// Defer to avoid re-entrant read on Room while
|
// Defer to avoid re-entrant read on Room while
|
||||||
// emit_refresh holds a write lock (via refresh_rooms).
|
// emit_refresh holds a write lock (via refresh_rooms).
|
||||||
@@ -331,24 +342,49 @@ impl ChatPanel {
|
|||||||
// Get the message which includes all attachments
|
// Get the message which includes all attachments
|
||||||
let content = self.get_input_value(cx);
|
let content = self.get_input_value(cx);
|
||||||
|
|
||||||
|
// Get the replies to this message
|
||||||
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||||
|
|
||||||
// Return if message is empty
|
// Return if message is empty
|
||||||
if content.trim().is_empty() {
|
if content.trim().is_empty() {
|
||||||
window.push_notification("Cannot send an empty message", cx);
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.send_message(&content, window, cx);
|
self.send_message(&content, replies, false, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_reaction(
|
||||||
|
&mut self,
|
||||||
|
emoji: &str,
|
||||||
|
target: &EventId,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
// Return if emoji is empty
|
||||||
|
if emoji.trim().is_empty() {
|
||||||
|
window.push_notification("Cannot send an empty reaction", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.send_message(emoji, vec![*target], true, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a message to all members of the chat
|
/// Send a message to all members of the chat
|
||||||
fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context<Self>) {
|
fn send_message(
|
||||||
|
&mut self,
|
||||||
|
value: &str,
|
||||||
|
replies: Vec<EventId>,
|
||||||
|
reaction: bool,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
if value.trim().is_empty() {
|
if value.trim().is_empty() {
|
||||||
window.push_notification("Cannot send an empty message", cx);
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let room = self.room.clone();
|
let room = self.room.clone();
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
|
||||||
let content = value.to_string();
|
let content = value.to_string();
|
||||||
let sent_ids = self.sent_ids.clone();
|
let sent_ids = self.sent_ids.clone();
|
||||||
|
|
||||||
@@ -356,8 +392,10 @@ impl ChatPanel {
|
|||||||
let Some(room_entity) = room.upgrade() else {
|
let Some(room_entity) = room.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create rumor and send task
|
||||||
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
||||||
let rumor = room.rumor(content.clone(), replies, cx)?;
|
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
|
||||||
let send_task = room.send(rumor.clone(), cx)?;
|
let send_task = room.send(rumor.clone(), cx)?;
|
||||||
Some((rumor, send_task))
|
Some((rumor, send_task))
|
||||||
}) {
|
}) {
|
||||||
@@ -371,9 +409,15 @@ impl ChatPanel {
|
|||||||
let id = rumor.id.expect("rumor must have an id");
|
let id = rumor.id.expect("rumor must have an id");
|
||||||
|
|
||||||
// Insert optimistic message and clear input
|
// Insert optimistic message and clear input
|
||||||
|
if rumor.kind != Kind::Reaction {
|
||||||
self.insert_message(&rumor, true, cx);
|
self.insert_message(&rumor, true, cx);
|
||||||
self.insert_reports(id, vec![], cx);
|
|
||||||
self.clear(window, cx);
|
self.clear(window, cx);
|
||||||
|
} else {
|
||||||
|
self.insert_reaction(&rumor, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update reports
|
||||||
|
self.insert_reports(id, vec![], cx);
|
||||||
|
|
||||||
// Spawn a single task to await the send and update reports
|
// Spawn a single task to await the send and update reports
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
@@ -444,11 +488,31 @@ impl ChatPanel {
|
|||||||
/// Convert and insert a vector of nostr events into the chat panel
|
/// Convert and insert a vector of nostr events into the chat panel
|
||||||
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
|
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
|
||||||
for event in events.iter() {
|
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
|
// Bulk inserting messages, so no need to scroll to the latest message
|
||||||
self.insert_message(event, false, cx);
|
self.insert_message(event, false, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert a reaction into the chat panel
|
||||||
|
fn insert_reaction(&mut self, event: &UnsignedEvent, cx: &mut Context<Self>) {
|
||||||
|
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
|
/// Check if a message has any reports
|
||||||
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
|
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
|
||||||
self.reports_by_id.read().unwrap().get(id).is_some()
|
self.reports_by_id.read().unwrap().get(id).is_some()
|
||||||
@@ -463,6 +527,16 @@ impl ChatPanel {
|
|||||||
self.messages.iter().find(|msg| &msg.id == id)
|
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
|
/// Scroll to a message by its ID
|
||||||
fn scroll_to(&self, id: &EventId) {
|
fn scroll_to(&self, id: &EventId) {
|
||||||
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
|
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
|
||||||
@@ -575,7 +649,10 @@ impl ChatPanel {
|
|||||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
match command {
|
match command {
|
||||||
Command::Insert(content) => {
|
Command::Insert(content) => {
|
||||||
self.send_message(content, window, cx);
|
self.input.update(cx, |this, cx| {
|
||||||
|
let new_value = format!("{} {}", this.value(), content);
|
||||||
|
this.set_value(new_value, window, cx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Command::ChangeSubject(subject) => {
|
Command::ChangeSubject(subject) => {
|
||||||
if self
|
if self
|
||||||
@@ -834,6 +911,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
let replies = message.replies_to.as_slice();
|
let replies = message.replies_to.as_slice();
|
||||||
let has_replies = !replies.is_empty();
|
let has_replies = !replies.is_empty();
|
||||||
|
let has_reactions = self.has_reaction(&id);
|
||||||
let has_reports = self.has_reports(&id, cx);
|
let has_reports = self.has_reports(&id, cx);
|
||||||
|
|
||||||
// Hide avatar setting
|
// Hide avatar setting
|
||||||
@@ -879,12 +957,7 @@ impl ChatPanel {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(cx.theme().text_placeholder)
|
.text_color(cx.theme().text_placeholder)
|
||||||
.child(
|
.child(div().font_semibold().child(author.name()))
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text)
|
|
||||||
.child(author.name()),
|
|
||||||
)
|
|
||||||
.child(message.created_at.to_human_time())
|
.child(message.created_at.to_human_time())
|
||||||
.when(has_reports, |this| {
|
.when(has_reports, |this| {
|
||||||
this.child(self.render_sent_reports(&id, cx))
|
this.child(self.render_sent_reports(&id, cx))
|
||||||
@@ -895,7 +968,10 @@ impl ChatPanel {
|
|||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.child(rendered_text)
|
.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(
|
.child(
|
||||||
@@ -990,13 +1066,9 @@ impl ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.px_2()
|
.px_2()
|
||||||
.border_l_2()
|
.border_l_2()
|
||||||
.border_color(cx.theme().element_selected)
|
.border_color(cx.theme().element_active)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.child(
|
.child(div().font_semibold().child(author.name()))
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_accent)
|
|
||||||
.child(author.name()),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1017,6 +1089,43 @@ impl ChatPanel {
|
|||||||
items
|
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<SharedString, Vec<PublicKey>> = 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 {
|
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
||||||
let reports = self.sent_reports(id, cx);
|
let reports = self.sent_reports(id, cx);
|
||||||
|
|
||||||
@@ -1192,6 +1301,29 @@ impl ChatPanel {
|
|||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.bg(cx.theme().background)
|
.bg(cx.theme().background)
|
||||||
|
.children({
|
||||||
|
let mut items = vec![];
|
||||||
|
|
||||||
|
for emoji in COMPACT_REACTION_EMOJIS {
|
||||||
|
items.push(
|
||||||
|
Button::new(*emoji)
|
||||||
|
.label(*emoji)
|
||||||
|
.tooltip(*emoji)
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click({
|
||||||
|
let emoji = *emoji;
|
||||||
|
let id = *id;
|
||||||
|
cx.listener(move |this, _event, window, cx| {
|
||||||
|
this.send_reaction(emoji, &id, window, cx);
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
})
|
||||||
|
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
||||||
.child(
|
.child(
|
||||||
Button::new("reply")
|
Button::new("reply")
|
||||||
.icon(IconName::Reply)
|
.icon(IconName::Reply)
|
||||||
@@ -1305,7 +1437,7 @@ impl ChatPanel {
|
|||||||
.gap_1()
|
.gap_1()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::from("Replying to:"))
|
.child("Replying to:")
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_color(cx.theme().text_accent)
|
.text_color(cx.theme().text_accent)
|
||||||
@@ -1402,15 +1534,10 @@ impl ChatPanel {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
.large()
|
||||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||||
this.horizontal()
|
let menu = this.horizontal();
|
||||||
.menu("👍", Box::new(Command::Insert("👍")))
|
REACTION_EMOJIS.iter().fold(menu, |this, emoji| {
|
||||||
.menu("👎", Box::new(Command::Insert("👎")))
|
this.menu(*emoji, Box::new(Command::Insert(emoji)))
|
||||||
.menu("😄", Box::new(Command::Insert("😄")))
|
})
|
||||||
.menu("🎉", Box::new(Command::Insert("🎉")))
|
|
||||||
.menu("😕", Box::new(Command::Insert("😕")))
|
|
||||||
.menu("❤️", Box::new(Command::Insert("❤️")))
|
|
||||||
.menu("🚀", Box::new(Command::Insert("🚀")))
|
|
||||||
.menu("👀", Box::new(Command::Insert("👀")))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user