chore: improve chat panel (#121)

* .

* .

* .

* skip sent message

* improve sent reports

* .

* .

* .
This commit is contained in:
reya
2025-08-18 13:20:29 +07:00
committed by GitHub
parent 5bef1a2c6c
commit c2b276f3f3
12 changed files with 1031 additions and 918 deletions

58
Cargo.lock generated
View File

@@ -178,7 +178,7 @@ dependencies = [
[[package]] [[package]]
name = "assets" name = "assets"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui", "gpui",
@@ -417,7 +417,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "auto_update" name = "auto_update"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo-packager-updater", "cargo-packager-updater",
@@ -1021,7 +1021,7 @@ dependencies = [
[[package]] [[package]]
name = "client_keys" name = "client_keys"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"global", "global",
@@ -1123,7 +1123,7 @@ dependencies = [
[[package]] [[package]]
name = "collections" name = "collections"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
@@ -1158,7 +1158,7 @@ dependencies = [
[[package]] [[package]]
name = "common" name = "common"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -1214,7 +1214,7 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]] [[package]]
name = "coop" name = "coop"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assets", "assets",
@@ -1544,7 +1544,7 @@ dependencies = [
[[package]] [[package]]
name = "derive_refineable" name = "derive_refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2342,7 +2342,7 @@ checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]] [[package]]
name = "global" name = "global"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dirs 5.0.1", "dirs 5.0.1",
@@ -2436,7 +2436,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui" name = "gpui"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"as-raw-xcb-connection", "as-raw-xcb-connection",
@@ -2529,7 +2529,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macros" name = "gpui_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -2541,7 +2541,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_tokio" name = "gpui_tokio"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"gpui", "gpui",
"tokio", "tokio",
@@ -2772,7 +2772,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client" name = "http_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2792,7 +2792,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client_tls" name = "http_client_tls"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"rustls", "rustls",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -2886,7 +2886,7 @@ dependencies = [
[[package]] [[package]]
name = "i18n" name = "i18n"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"rust-i18n", "rust-i18n",
] ]
@@ -3003,7 +3003,7 @@ dependencies = [
[[package]] [[package]]
name = "identity" name = "identity"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"client_keys", "client_keys",
@@ -3594,7 +3594,7 @@ dependencies = [
[[package]] [[package]]
name = "media" name = "media"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen 0.71.1", "bindgen 0.71.1",
@@ -4632,9 +4632,9 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.95" version = "1.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -4984,7 +4984,7 @@ dependencies = [
[[package]] [[package]]
name = "refineable" name = "refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"derive_refineable", "derive_refineable",
"workspace-hack", "workspace-hack",
@@ -5021,7 +5021,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "registry" name = "registry"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5029,13 +5029,11 @@ dependencies = [
"fuzzy-matcher", "fuzzy-matcher",
"global", "global",
"gpui", "gpui",
"i18n",
"itertools 0.13.0", "itertools 0.13.0",
"log", "log",
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"oneshot", "oneshot",
"rust-i18n",
"settings", "settings",
"smallvec", "smallvec",
"smol", "smol",
@@ -5142,7 +5140,7 @@ dependencies = [
[[package]] [[package]]
name = "reqwest_client" name = "reqwest_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -5678,7 +5676,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749"
[[package]] [[package]]
name = "semantic_version" name = "semantic_version"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@@ -5813,7 +5811,7 @@ dependencies = [
[[package]] [[package]]
name = "settings" name = "settings"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"global", "global",
@@ -6073,7 +6071,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sum_tree" name = "sum_tree"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"log", "log",
@@ -6376,7 +6374,7 @@ dependencies = [
[[package]] [[package]]
name = "theme" name = "theme"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui", "gpui",
@@ -6536,7 +6534,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "title_bar" name = "title_bar"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
@@ -6907,7 +6905,7 @@ dependencies = [
[[package]] [[package]]
name = "ui" name = "ui"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"common", "common",
@@ -7107,7 +7105,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "util" name = "util"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-fs", "async-fs",

3
assets/icons/sent.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m7.25 10.488 3.675 3.762 6.825-10m-14 10.5v2.3c0 1.12 0 1.68.218 2.108a2 2 0 0 0 .874.874c.428.218.988.218 2.108.218h10.1c1.12 0 1.68 0 2.108-.218a2 2 0 0 0 .874-.874c.218-.428.218-.988.218-2.108v-2.3"/>
</svg>

After

Width:  |  Height:  |  Size: 406 B

View File

@@ -155,12 +155,19 @@ impl ChatSpace {
if let Some(room) = room.upgrade() { if let Some(room) = room.upgrade() {
this.dock.update(cx, |this, cx| { this.dock.update(cx, |this, cx| {
let panel = chat::init(room, window, cx); let panel = chat::init(room, window, cx);
// Load messages on panel creation
// Load messages when the panel is created
panel.update(cx, |this, cx| { panel.update(cx, |this, cx| {
this.load_messages(window, cx); this.load_messages(window, cx);
}); });
this.add_panel(panel, DockPlacement::Center, window, cx); // Add the panel to the center dock (tabs)
this.add_panel(
Arc::new(panel),
DockPlacement::Center,
window,
cx,
);
}); });
} else { } else {
window.push_notification(t!("common.room_error"), cx); window.push_notification(t!("common.room_error"), cx);

View File

@@ -148,7 +148,7 @@ fn main() {
match smol::future::or(recv(), timeout()).await { match smol::future::or(recv(), timeout()).await {
Some(event) => { Some(event) => {
let cached = try_unwrap_event(&event, &signal_tx, &mta_tx).await; let cached = unwrap_gift(&event, &signal_tx, &mta_tx).await;
// Increment the total messages counter if message is not from cache // Increment the total messages counter if message is not from cache
if !cached { if !cached {
@@ -521,21 +521,21 @@ async fn get_unwrapped(root: EventId) -> Result<Event, Error> {
} }
/// Unwraps a gift-wrapped event and processes its contents. /// Unwraps a gift-wrapped event and processes its contents.
async fn try_unwrap_event( async fn unwrap_gift(
event: &Event, gift: &Event,
signal_tx: &Sender<NostrSignal>, signal_tx: &Sender<NostrSignal>,
mta_tx: &Sender<PublicKey>, mta_tx: &Sender<PublicKey>,
) -> bool { ) -> bool {
let client = nostr_client(); let client = nostr_client();
let mut is_cached = false; let mut is_cached = false;
let event = match get_unwrapped(event.id).await { let event = match get_unwrapped(gift.id).await {
Ok(event) => { Ok(event) => {
is_cached = true; is_cached = true;
event event
} }
Err(_) => { Err(_) => {
match client.unwrap_gift_wrap(event).await { match client.unwrap_gift_wrap(gift).await {
Ok(unwrap) => { Ok(unwrap) => {
// Sign the unwrapped event with a RANDOM KEYS // Sign the unwrapped event with a RANDOM KEYS
let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else { let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else {
@@ -544,7 +544,7 @@ async fn try_unwrap_event(
}; };
// Save this event to the database for future use. // Save this event to the database for future use.
if let Err(e) = set_unwrapped(event.id, &unwrapped).await { if let Err(e) = set_unwrapped(gift.id, &unwrapped).await {
log::warn!("Failed to cache unwrapped event: {e}") log::warn!("Failed to cache unwrapped event: {e}")
} }

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,6 @@ common = { path = "../common" }
global = { path = "../global" } global = { path = "../global" }
settings = { path = "../settings" } settings = { path = "../settings" }
rust-i18n.workspace = true
i18n.workspace = true
gpui.workspace = true gpui.workspace = true
nostr.workspace = true nostr.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true

View File

@@ -20,8 +20,6 @@ use crate::room::Room;
pub mod message; pub mod message;
pub mod room; pub mod room;
i18n::init!();
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
Registry::set_global(cx.new(Registry::new), cx); Registry::set_global(cx.new(Registry::new), cx);
} }
@@ -421,8 +419,8 @@ impl Registry {
} }
// Emit the new message to the room // Emit the new message to the room
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, move |this, _window, cx| {
this.emit_message(event, window, cx); this.emit_message(event, cx);
}); });
}); });

View File

@@ -1,19 +1,11 @@
use std::hash::Hash; use std::hash::Hash;
use std::iter::IntoIterator;
use chrono::{Local, TimeZone}; use chrono::{Local, TimeZone};
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::room::SendError;
/// Represents a message in the chat system.
///
/// Contains information about the message content, author, creation time,
/// mentions, replies, and any errors that occurred during sending.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Message { pub struct RenderedMessage {
/// Unique identifier of the message (EventId from nostr_sdk)
pub id: EventId, pub id: EventId,
/// Author's public key /// Author's public key
pub author: PublicKey, pub author: PublicKey,
@@ -23,138 +15,82 @@ 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<PublicKey>, pub mentions: Vec<PublicKey>,
/// List of EventIds this message is replying to /// List of event of the message this message is a reply to
pub replies_to: Option<Vec<EventId>>, pub replies_to: Vec<EventId>,
/// Any errors that occurred while sending this message
pub errors: Option<Vec<SendError>>,
} }
impl Eq for Message {} impl From<Event> for RenderedMessage {
fn from(inner: Event) -> Self {
let mentions = extract_mentions(&inner.content);
let replies_to = extract_reply_ids(&inner.tags);
impl PartialEq for Message { Self {
id: inner.id,
author: inner.pubkey,
content: inner.content.into(),
created_at: inner.created_at,
mentions,
replies_to,
}
}
}
impl From<UnsignedEvent> for RenderedMessage {
fn from(inner: UnsignedEvent) -> Self {
let mentions = extract_mentions(&inner.content);
let replies_to = extract_reply_ids(&inner.tags);
Self {
// Event ID must be known
id: inner.id.unwrap(),
author: inner.pubkey,
content: inner.content.into(),
created_at: inner.created_at,
mentions,
replies_to,
}
}
}
impl From<Box<Event>> for RenderedMessage {
fn from(inner: Box<Event>) -> Self {
(*inner).into()
}
}
impl From<&Box<Event>> for RenderedMessage {
fn from(inner: &Box<Event>) -> Self {
inner.to_owned().into()
}
}
impl Eq for RenderedMessage {}
impl PartialEq for RenderedMessage {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.id == other.id self.id == other.id
} }
} }
impl Ord for Message { impl Ord for RenderedMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering { fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.created_at.cmp(&other.created_at) self.created_at.cmp(&other.created_at)
} }
} }
impl PartialOrd for Message { impl PartialOrd for RenderedMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other)) Some(self.cmp(other))
} }
} }
impl Hash for Message { impl Hash for RenderedMessage {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state); self.id.hash(state);
} }
} }
/// Builder pattern implementation for constructing Message objects. impl RenderedMessage {
#[derive(Debug)]
pub struct MessageBuilder {
id: EventId,
author: PublicKey,
content: Option<SharedString>,
created_at: Option<Timestamp>,
mentions: Vec<PublicKey>,
replies_to: Option<Vec<EventId>>,
errors: Option<Vec<SendError>>,
}
impl MessageBuilder {
/// Creates a new MessageBuilder with default values
pub fn new(id: EventId, author: PublicKey) -> Self {
Self {
id,
author,
content: None,
created_at: None,
mentions: vec![],
replies_to: None,
errors: None,
}
}
/// Sets the message content
pub fn content(mut self, content: impl Into<SharedString>) -> Self {
self.content = Some(content.into());
self
}
/// Sets the creation timestamp
pub fn created_at(mut self, created_at: Timestamp) -> Self {
self.created_at = Some(created_at);
self
}
/// Adds a single mention to the message
pub fn mention(mut self, mention: PublicKey) -> Self {
self.mentions.push(mention);
self
}
/// Adds multiple mentions to the message
pub fn mentions<I>(mut self, mentions: I) -> Self
where
I: IntoIterator<Item = PublicKey>,
{
self.mentions.extend(mentions);
self
}
/// Sets a single message this is replying to
pub fn reply_to(mut self, reply_to: EventId) -> Self {
self.replies_to = Some(vec![reply_to]);
self
}
/// Sets multiple messages this is replying to
pub fn replies_to<I>(mut self, replies_to: I) -> Self
where
I: IntoIterator<Item = EventId>,
{
let replies: Vec<EventId> = replies_to.into_iter().collect();
if !replies.is_empty() {
self.replies_to = Some(replies);
}
self
}
/// Adds errors that occurred during sending
pub fn errors<I>(mut self, errors: I) -> Self
where
I: IntoIterator<Item = SendError>,
{
self.errors = Some(errors.into_iter().collect());
self
}
/// Builds the message
pub fn build(self) -> Result<Message, String> {
Ok(Message {
id: self.id,
author: self.author,
content: self.content.ok_or("Content is required")?,
created_at: self.created_at.unwrap_or_else(Timestamp::now),
mentions: self.mentions,
replies_to: self.replies_to,
errors: self.errors,
})
}
}
impl Message {
/// Creates a new MessageBuilder
pub fn builder(id: EventId, author: PublicKey) -> MessageBuilder {
MessageBuilder::new(id, author)
}
/// Returns a human-readable string representing how long ago the message was created /// Returns a human-readable string representing how long ago the message was created
pub fn ago(&self) -> SharedString { pub fn ago(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) { let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) {
@@ -177,3 +113,41 @@ impl Message {
.into() .into()
} }
} }
fn extract_mentions(content: &str) -> Vec<PublicKey> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>()
}
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
for tag in inner.filter(TagKind::e()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
for tag in inner.filter(TagKind::q()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
replies_to
}

View File

@@ -5,12 +5,11 @@ use chrono::{Local, TimeZone};
use common::display::DisplayProfile; use common::display::DisplayProfile;
use common::event::EventUtils; use common::event::EventUtils;
use global::nostr_client; use global::nostr_client;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::SmallVec; use smallvec::SmallVec;
use crate::message::Message;
use crate::Registry; use crate::Registry;
pub(crate) const NOW: &str = "now"; pub(crate) const NOW: &str = "now";
@@ -20,15 +19,58 @@ pub(crate) const HOURS_IN_DAY: i64 = 24;
pub(crate) const DAYS_IN_MONTH: i64 = 30; pub(crate) const DAYS_IN_MONTH: i64 = 30;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum RoomSignal { pub struct SendReport {
NewMessage(Message), pub receiver: PublicKey,
Refresh, pub output: Option<Output<EventId>>,
pub local_error: Option<SharedString>,
pub nip17_relays_not_found: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq)] impl SendReport {
pub struct SendError { pub fn output(receiver: PublicKey, output: Output<EventId>) -> Self {
pub profile: Profile, Self {
pub message: SharedString, receiver,
output: Some(output),
local_error: None,
nip17_relays_not_found: false,
}
}
pub fn error(receiver: PublicKey, error: impl Into<SharedString>) -> Self {
Self {
receiver,
output: None,
local_error: Some(error.into()),
nip17_relays_not_found: false,
}
}
pub fn nip17_relays_not_found(receiver: PublicKey) -> Self {
Self {
receiver,
output: None,
local_error: None,
nip17_relays_not_found: true,
}
}
pub fn is_relay_error(&self) -> bool {
self.local_error.is_some() || self.nip17_relays_not_found
}
pub fn is_sent_success(&self) -> bool {
if let Some(output) = self.output.as_ref() {
!output.success.is_empty()
} else {
false
}
}
}
#[derive(Debug, Clone)]
pub enum RoomSignal {
NewMessage(Box<Event>),
Refresh,
} }
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] #[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
@@ -343,201 +385,80 @@ impl Room {
/// ///
/// # Returns /// # Returns
/// ///
/// A Task that resolves to Result<Vec<RoomMessage>, Error> containing all messages for this room /// A Task that resolves to Result<Vec<Event>, Error> containing all messages for this room
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<Message>, Error>> { pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<Event>, Error>> {
let pubkeys = self.members.clone(); let members = self.members.clone();
let members_clone = members.clone();
let filter = Filter::new()
.kind(Kind::PrivateDirectMessage)
.authors(self.members.clone())
.pubkeys(self.members.clone());
cx.background_spawn(async move { cx.background_spawn(async move {
let mut messages = vec![]; let client = nostr_client();
let parser = NostrParser::new(); let signer = client.signer().await?;
let database = nostr_client().database(); let public_key = signer.get_public_key().await?;
// Get all events from database let send = Filter::new()
let events = database .kind(Kind::PrivateDirectMessage)
.query(filter) .author(public_key)
.await? .pubkeys(members.clone());
let recv = Filter::new()
.kind(Kind::PrivateDirectMessage)
.authors(members)
.pubkey(public_key);
let send_events = client.database().query(send).await?;
let recv_events = client.database().query(recv).await?;
let events = send_events
.merge(recv_events)
.into_iter() .into_iter()
.sorted_by_key(|ev| ev.created_at) .sorted_by_key(|ev| ev.created_at)
.filter(|ev| ev.compare_pubkeys(&pubkeys)) .filter(|ev| ev.compare_pubkeys(&members_clone))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for event in events.into_iter() { Ok(events)
let content = event.content.clone();
let tokens = parser.parse(&content);
let mut replies_to = vec![];
for tag in event.tags.filter(TagKind::e()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
for tag in event.tags.filter(TagKind::q()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
let mentions = tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>();
if let Ok(message) = Message::builder(event.id, event.pubkey)
.content(content)
.created_at(event.created_at)
.replies_to(replies_to)
.mentions(mentions)
.build()
{
messages.push(message);
}
}
Ok(messages)
}) })
} }
/// Emits a message event to the GPUI /// Emits a new message signal to the current room
/// pub fn emit_message(&self, event: Event, cx: &mut Context<Self>) {
/// # Arguments cx.emit(RoomSignal::NewMessage(Box::new(event)));
///
/// * `event` - The Nostr event to emit
/// * `window` - The Window to emit the event to
/// * `cx` - The context for the room
///
/// # Effects
///
/// Processes the event and emits an Incoming to the UI when complete
pub fn emit_message(&self, event: Event, _window: &mut Window, cx: &mut Context<Self>) {
// Extract all mentions from content
let mentions = extract_mentions(&event.content);
// Extract reply_to if present
let mut replies_to = vec![];
for tag in event.tags.filter(TagKind::e()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
for tag in event.tags.filter(TagKind::q()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
if let Ok(message) = Message::builder(event.id, event.pubkey)
.content(event.content)
.created_at(event.created_at)
.replies_to(replies_to)
.mentions(mentions)
.build()
{
cx.emit(RoomSignal::NewMessage(message));
}
} }
/// Emits a signal to refresh the current room's messages. /// Emits a signal to refresh the current room's messages.
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) { pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
cx.emit(RoomSignal::Refresh); cx.emit(RoomSignal::Refresh);
log::info!("refresh room: {}", self.id);
} }
/// Creates a temporary message for optimistic updates /// Creates a temporary message for optimistic updates
/// ///
/// This constructs an unsigned message with the current user as the author, /// The event must not been published to relays.
/// extracts any mentions from the content, and packages it as a Message struct.
/// The message will have a generated ID but hasn't been published to relays.
///
/// # Arguments
///
/// * `content` - The message content text
/// * `cx` - The application context containing user profile information
///
/// # Returns
///
/// Returns `Some(Message)` containing the temporary message if the current user's profile is available,
/// or `None` if no account is found.
pub fn create_temp_message( pub fn create_temp_message(
&self, &self,
public_key: PublicKey, receiver: PublicKey,
content: &str, content: &str,
replies: Option<&Vec<Message>>, replies: &[EventId],
) -> Option<Message> { ) -> UnsignedEvent {
let builder = EventBuilder::private_msg_rumor(public_key, content); let builder = EventBuilder::private_msg_rumor(receiver, content);
let mut tags = vec![];
// Add event reference if it's present (replying to another event) // Add event reference if it's present (replying to another event)
let mut refs = vec![]; if replies.len() == 1 {
tags.push(Tag::event(replies[0]))
if let Some(replies) = replies { } else {
if replies.len() == 1 { for id in replies.iter() {
refs.push(Tag::event(replies[0].id)) tags.push(Tag::from_standardized(TagStandard::Quote {
} else { event_id: id.to_owned(),
for message in replies.iter() { relay_url: None,
refs.push(Tag::custom(TagKind::q(), vec![message.id])) public_key: None,
} }))
} }
} }
let mut event = if !refs.is_empty() { let mut event = builder.tags(tags).build(receiver);
builder.tags(refs).build(public_key) // Ensure event ID is set
} else {
builder.build(public_key)
};
// Create a unsigned event to convert to Coop Message
event.ensure_id(); event.ensure_id();
// Extract all mentions from content event
let mentions = extract_mentions(&event.content);
// Extract reply_to if present
let mut replies_to = vec![];
for tag in event.tags.filter(TagKind::e()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
for tag in event.tags.filter(TagKind::q()) {
if let Some(content) = tag.content() {
if let Ok(id) = EventId::from_hex(content) {
replies_to.push(id);
}
}
}
Message::builder(event.id.unwrap(), public_key)
.content(event.content)
.created_at(event.created_at)
.replies_to(replies_to)
.mentions(mentions)
.build()
.ok()
} }
/// Sends a message to all members in the background task /// Sends a message to all members in the background task
@@ -554,12 +475,11 @@ impl Room {
pub fn send_in_background( pub fn send_in_background(
&self, &self,
content: &str, content: &str,
replies: Option<&Vec<Message>>, replies: Vec<EventId>,
backup: bool, backup: bool,
cx: &App, cx: &App,
) -> Task<Result<Vec<SendError>, Error>> { ) -> Task<Result<Vec<SendReport>, Error>> {
let content = content.to_owned(); let content = content.to_owned();
let replies = replies.cloned();
let subject = self.subject.clone(); let subject = self.subject.clone();
let picture = self.picture.clone(); let picture = self.picture.clone();
let public_keys = self.members.clone(); let public_keys = self.members.clone();
@@ -569,8 +489,7 @@ impl Room {
let signer = client.signer().await?; let signer = client.signer().await?;
let public_key = signer.get_public_key().await?; let public_key = signer.get_public_key().await?;
let mut reports = vec![]; let mut tags = public_keys
let mut tags: Vec<Tag> = public_keys
.iter() .iter()
.filter_map(|pubkey| { .filter_map(|pubkey| {
if pubkey != &public_key { if pubkey != &public_key {
@@ -579,16 +498,18 @@ impl Room {
None None
} }
}) })
.collect(); .collect_vec();
// Add event reference if it's present (replying to another event) // Add event reference if it's present (replying to another event)
if let Some(replies) = replies { if replies.len() == 1 {
if replies.len() == 1 { tags.push(Tag::event(replies[0]))
tags.push(Tag::event(replies[0].id)) } else {
} else { for id in replies.iter() {
for message in replies.iter() { tags.push(Tag::from_standardized(TagStandard::Quote {
tags.push(Tag::custom(TagKind::q(), vec![message.id])) event_id: id.to_owned(),
} relay_url: None,
public_key: None,
}))
} }
} }
@@ -608,43 +529,43 @@ impl Room {
return Err(anyhow!("Something is wrong. Cannot get receivers list.")); return Err(anyhow!("Something is wrong. Cannot get receivers list."));
}; };
// Stored all send errors
let mut reports = vec![];
for receiver in receivers.iter() { for receiver in receivers.iter() {
if let Err(e) = client match client
.send_private_msg(*receiver, &content, tags.clone()) .send_private_msg(*receiver, &content, tags.clone())
.await .await
{ {
let metadata = client Ok(output) => {
.database() reports.push(SendReport::output(*receiver, output));
.metadata(*receiver) }
.await? Err(e) => {
.unwrap_or_default(); if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e {
let profile = Profile::new(*receiver, metadata); reports.push(SendReport::nip17_relays_not_found(*receiver));
let report = SendError { } else {
profile, reports.push(SendReport::error(*receiver, e.to_string()));
message: e.to_string().into(), }
}; }
reports.push(report);
} }
} }
// Only send a backup message to current user if there are no issues when sending to others // Only send a backup message to current user if sent successfully to others
if backup && reports.is_empty() { if reports.iter().all(|r| r.is_sent_success()) && backup {
if let Err(e) = client match client
.send_private_msg(*current_user, &content, tags.clone()) .send_private_msg(*current_user, &content, tags.clone())
.await .await
{ {
let metadata = client Ok(output) => {
.database() reports.push(SendReport::output(*current_user, output));
.metadata(*current_user) }
.await? Err(e) => {
.unwrap_or_default(); if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e {
let profile = Profile::new(*current_user, metadata); reports.push(SendReport::nip17_relays_not_found(*current_user));
let report = SendError { } else {
profile, reports.push(SendReport::error(*current_user, e.to_string()));
message: e.to_string().into(), }
}; }
reports.push(report);
} }
} }
@@ -652,19 +573,3 @@ impl Room {
}) })
} }
} }
pub(crate) fn extract_mentions(content: &str) -> Vec<PublicKey> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>()
}

View File

@@ -61,6 +61,7 @@ pub enum IconName {
Forward, Forward,
Search, Search,
SearchFill, SearchFill,
Sent,
Settings, Settings,
SortAscending, SortAscending,
SortDescending, SortDescending,
@@ -133,6 +134,7 @@ impl IconName {
Self::Forward => "icons/forward.svg", Self::Forward => "icons/forward.svg",
Self::Search => "icons/search.svg", Self::Search => "icons/search.svg",
Self::SearchFill => "icons/search-fill.svg", Self::SearchFill => "icons/search-fill.svg",
Self::Sent => "icons/sent.svg",
Self::Settings => "icons/settings.svg", Self::Settings => "icons/settings.svg",
Self::SortAscending => "icons/sort-ascending.svg", Self::SortAscending => "icons/sort-ascending.svg",
Self::SortDescending => "icons/sort-descending.svg", Self::SortDescending => "icons/sort-descending.svg",

View File

@@ -54,7 +54,7 @@ type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>; Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[derive(Default)] #[derive(Default)]
pub struct RichText { pub struct RenderedText {
pub text: SharedString, pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>, pub highlights: Vec<(Range<usize>, Highlight)>,
pub link_ranges: Vec<Range<usize>>, pub link_ranges: Vec<Range<usize>>,
@@ -63,7 +63,7 @@ pub struct RichText {
custom_ranges_tooltip_fn: CustomRangeTooltipFn, custom_ranges_tooltip_fn: CustomRangeTooltipFn,
} }
impl RichText { impl RenderedText {
pub fn new(content: &str, cx: &App) -> Self { pub fn new(content: &str, cx: &App) -> Self {
let mut text = String::new(); let mut text = String::new();
let mut highlights = Vec::new(); let mut highlights = Vec::new();
@@ -81,7 +81,7 @@ impl RichText {
text.truncate(text.trim_end().len()); text.truncate(text.trim_end().len());
RichText { RenderedText {
text: SharedString::from(text), text: SharedString::from(text),
link_urls: link_urls.into(), link_urls: link_urls.into(),
link_ranges, link_ranges,
@@ -98,7 +98,7 @@ impl RichText {
self.custom_ranges_tooltip_fn = Some(Arc::new(f)); self.custom_ranges_tooltip_fn = Some(Arc::new(f));
} }
pub fn element(&self, id: ElementId, window: &mut Window, cx: &App) -> AnyElement { pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let link_color = cx.theme().text_accent; let link_color = cx.theme().text_accent;
InteractiveText::new( InteractiveText::new(

View File

@@ -287,7 +287,7 @@ compose:
en: "Subject:" en: "Subject:"
chat: chat:
private_conversation_notice: notice:
en: "This conversation is private. Only members can see each other's messages." en: "This conversation is private. Only members can see each other's messages."
placeholder: placeholder:
en: "Message..." en: "Message..."
@@ -303,12 +303,18 @@ chat:
en: "Change the subject of the conversation" en: "Change the subject of the conversation"
replying_to_label: replying_to_label:
en: "Replying to:" en: "Replying to:"
send_fail: sent_to:
en: "Sent to:"
sent:
en: "• Sent"
sent_failed:
en: "Failed to send message. Click to see details." en: "Failed to send message. Click to see details."
logs_title: sent_success:
en: "Error Logs" en: "Successfully"
send_to_label: reports:
en: "Send to:" en: "Sent Reports"
nip17_not_found:
en: "%{u} has not set up Messaging Relays, so they won't receive your message."
sidebar: sidebar:
find_or_start_conversation: find_or_start_conversation: