feat: Reply or Reference a specific message (#39)
* add reply to when send message * show reply message * refactor * multiple quote
This commit is contained in:
3
assets/icons/forward.svg
Normal file
3
assets/icons/forward.svg
Normal 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-linejoin="round" stroke-width="1.5" d="m21.76 11.45-8.146-7.535a.75.75 0 0 0-1.26.55V8a.51.51 0 0 1-.504.504C3.765 8.632 1.604 11.92 1.604 20.25c1.47-2.94 2.22-4.679 10.245-4.748a.501.501 0 0 1 .505.498v3.535a.75.75 0 0 0 1.26.55l8.145-7.535a.75.75 0 0 0 0-1.1Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 405 B |
3
assets/icons/reply.svg
Normal file
3
assets/icons/reply.svg
Normal 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-linejoin="round" stroke-width="1.5" d="m1.845 11.45 8.146-7.535a.75.75 0 0 1 1.259.55V8c0 .276.228.5.504.504C19.84 8.632 22 11.92 22 20.25c-1.47-2.94-2.22-4.679-10.245-4.748a.501.501 0 0 0-.505.498v3.535a.75.75 0 0 1-1.26.55L1.846 12.55a.75.75 0 0 1 0-1.1Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 400 B |
@@ -1,60 +1,152 @@
|
|||||||
use chrono::{Local, TimeZone};
|
use chrono::{Local, TimeZone};
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use std::{cell::RefCell, iter::IntoIterator, rc::Rc};
|
||||||
|
|
||||||
use crate::room::SendError;
|
use crate::room::SendError;
|
||||||
|
|
||||||
/// # Message
|
/// Represents a message in the chat system.
|
||||||
///
|
///
|
||||||
/// Represents a message in the application.
|
/// Contains information about the message content, author, creation time,
|
||||||
///
|
/// mentions, replies, and any errors that occurred during sending.
|
||||||
/// ## Fields
|
|
||||||
///
|
|
||||||
/// - `id`: The unique identifier for the message
|
|
||||||
/// - `content`: The text content of the message
|
|
||||||
/// - `author`: Profile information about who created the message
|
|
||||||
/// - `mentions`: List of profiles mentioned in the message
|
|
||||||
/// - `created_at`: Timestamp when the message was created
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub id: EventId,
|
/// Unique identifier of the message (EventId from nostr_sdk)
|
||||||
pub content: String,
|
pub id: Option<EventId>,
|
||||||
pub author: Profile,
|
/// Author profile information
|
||||||
|
pub author: Option<Profile>,
|
||||||
|
/// The content/text of the message
|
||||||
|
pub content: SharedString,
|
||||||
|
/// When the message was created
|
||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
|
/// List of mentioned profiles in the message
|
||||||
pub mentions: Vec<Profile>,
|
pub mentions: Vec<Profile>,
|
||||||
|
/// List of EventIds this message is replying to
|
||||||
|
pub replies_to: Option<Vec<EventId>>,
|
||||||
|
/// Any errors that occurred while sending this message
|
||||||
pub errors: Option<Vec<SendError>>,
|
pub errors: Option<Vec<SendError>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
/// Builder pattern implementation for constructing Message objects.
|
||||||
/// Creates a new message with the provided details
|
#[derive(Debug, Default)]
|
||||||
///
|
pub struct MessageBuilder {
|
||||||
/// # Arguments
|
id: Option<EventId>,
|
||||||
///
|
author: Option<Profile>,
|
||||||
/// * `id` - Unique event identifier
|
content: Option<String>,
|
||||||
/// * `content` - Message text content
|
created_at: Option<Timestamp>,
|
||||||
/// * `author` - Profile of the message author
|
mentions: Vec<Profile>,
|
||||||
/// * `created_at` - When the message was created
|
replies_to: Option<Vec<EventId>>,
|
||||||
///
|
errors: Option<Vec<SendError>>,
|
||||||
/// # Returns
|
}
|
||||||
///
|
|
||||||
/// A new `Message` instance
|
impl MessageBuilder {
|
||||||
pub fn new(id: EventId, content: String, author: Profile, created_at: Timestamp) -> Self {
|
/// Creates a new MessageBuilder with default values
|
||||||
Self {
|
pub fn new() -> Self {
|
||||||
id,
|
Self::default()
|
||||||
content,
|
}
|
||||||
author,
|
|
||||||
created_at,
|
/// Sets the message ID
|
||||||
mentions: vec![],
|
pub fn id(mut self, id: EventId) -> Self {
|
||||||
errors: None,
|
self.id = Some(id);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the message author
|
||||||
|
pub fn author(mut self, author: Profile) -> Self {
|
||||||
|
self.author = Some(author);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the message content
|
||||||
|
pub fn content(mut self, content: String) -> Self {
|
||||||
|
self.content = Some(content);
|
||||||
|
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: Profile) -> 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 = Profile>,
|
||||||
|
{
|
||||||
|
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 wrapped in an Rc<RefCell<Message>>
|
||||||
|
pub fn build_rc(self) -> Result<Rc<RefCell<Message>>, String> {
|
||||||
|
self.build().map(|m| Rc::new(RefCell::new(m)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")?.into(),
|
||||||
|
created_at: self.created_at.unwrap_or_else(Timestamp::now),
|
||||||
|
mentions: self.mentions,
|
||||||
|
replies_to: self.replies_to,
|
||||||
|
errors: self.errors,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats the message timestamp as a human-readable relative time
|
impl Message {
|
||||||
///
|
/// Creates a new MessageBuilder
|
||||||
/// # Returns
|
pub fn builder() -> MessageBuilder {
|
||||||
///
|
MessageBuilder::new()
|
||||||
/// A formatted string like "Today at 12:30 PM", "Yesterday at 3:45 PM",
|
}
|
||||||
/// or a date and time for older messages
|
|
||||||
|
/// Converts the message into an Rc<RefCell<Message>>
|
||||||
|
pub fn into_rc(self) -> Rc<RefCell<Self>> {
|
||||||
|
Rc::new(RefCell::new(self))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a message from a builder and wraps it in Rc<RefCell>
|
||||||
|
pub fn build_rc(builder: MessageBuilder) -> Result<Rc<RefCell<Self>>, String> {
|
||||||
|
builder.build().map(|m| Rc::new(RefCell::new(m)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
chrono::LocalResult::Single(time) => time,
|
chrono::LocalResult::Single(time) => time,
|
||||||
@@ -75,89 +167,4 @@ impl Message {
|
|||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds or replaces mentions in the message
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `mentions` - New list of mentioned profiles
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// The same message with updated mentions
|
|
||||||
pub fn with_mentions(mut self, mentions: impl IntoIterator<Item = Profile>) -> Self {
|
|
||||||
self.mentions.extend(mentions);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds or replaces errors in the message
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `errors` - New list of errors
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// The same message with updated errors
|
|
||||||
pub fn with_errors(mut self, errors: Vec<SendError>) -> Self {
|
|
||||||
self.errors = Some(errors);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # RoomMessage
|
|
||||||
///
|
|
||||||
/// Represents different types of messages that can appear in a room.
|
|
||||||
///
|
|
||||||
/// ## Variants
|
|
||||||
///
|
|
||||||
/// - `User`: A message sent by a user
|
|
||||||
/// - `System`: A message generated by the system
|
|
||||||
/// - `Announcement`: A special message type used for room announcements
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum RoomMessage {
|
|
||||||
/// User message
|
|
||||||
User(Box<Message>),
|
|
||||||
/// System message
|
|
||||||
System(SharedString),
|
|
||||||
/// Only use for UI purposes.
|
|
||||||
/// Placeholder will be used for display room announcement
|
|
||||||
Announcement,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoomMessage {
|
|
||||||
/// Creates a new user message
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `message` - The message content
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A `RoomMessage::User` variant
|
|
||||||
pub fn user(message: Message) -> Self {
|
|
||||||
Self::User(Box::new(message))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new system message
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `content` - The system message content
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A `RoomMessage::System` variant
|
|
||||||
pub fn system(content: SharedString) -> Self {
|
|
||||||
Self::System(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new announcement placeholder
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A `RoomMessage::Announcement` variant
|
|
||||||
pub fn announcement() -> Self {
|
|
||||||
Self::Announcement
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use nostr_sdk::prelude::*;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE},
|
constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE},
|
||||||
message::{Message, RoomMessage},
|
message::Message,
|
||||||
ChatRegistry,
|
ChatRegistry,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ impl Room {
|
|||||||
///
|
///
|
||||||
/// A Task that resolves to Result<Vec<RoomMessage>, Error> containing
|
/// A Task that resolves to Result<Vec<RoomMessage>, Error> containing
|
||||||
/// all messages for this room
|
/// all messages for this room
|
||||||
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<RoomMessage>, Error>> {
|
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<Message>, Error>> {
|
||||||
let client = get_client();
|
let client = get_client();
|
||||||
let pubkeys = Arc::clone(&self.members);
|
let pubkeys = Arc::clone(&self.members);
|
||||||
let profiles: Vec<Profile> = pubkeys
|
let profiles: Vec<Profile> = pubkeys
|
||||||
@@ -421,11 +421,26 @@ impl Room {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for event in events.into_iter() {
|
for event in events.into_iter() {
|
||||||
let id = event.id;
|
|
||||||
let created_at = event.created_at;
|
|
||||||
let content = event.content.clone();
|
let content = event.content.clone();
|
||||||
let tokens = parser.parse(&content);
|
let tokens = parser.parse(&content);
|
||||||
let mut mentions = vec![];
|
let mut mentions = vec![];
|
||||||
|
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 author = profiles
|
let author = profiles
|
||||||
.iter()
|
.iter()
|
||||||
@@ -454,10 +469,17 @@ impl Room {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Message::new(id, content, author, created_at).with_mentions(mentions);
|
if let Ok(message) = Message::builder()
|
||||||
let room_message = RoomMessage::user(message);
|
.id(event.id)
|
||||||
|
.content(content)
|
||||||
messages.push(room_message);
|
.author(author)
|
||||||
|
.created_at(event.created_at)
|
||||||
|
.replies_to(replies_to)
|
||||||
|
.mentions(mentions)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(messages)
|
Ok(messages)
|
||||||
@@ -477,12 +499,41 @@ impl Room {
|
|||||||
/// Processes the event and emits an Incoming to the UI when complete
|
/// 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>) {
|
pub fn emit_message(&self, event: Event, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let author = ChatRegistry::get_global(cx).profile(&event.pubkey, cx);
|
let author = ChatRegistry::get_global(cx).profile(&event.pubkey, cx);
|
||||||
let mentions = extract_mentions(&event.content, cx);
|
|
||||||
let message =
|
|
||||||
Message::new(event.id, event.content, author, event.created_at).with_mentions(mentions);
|
|
||||||
|
|
||||||
|
// Extract all mentions from content
|
||||||
|
let mentions = extract_mentions(&event.content, cx);
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
.id(event.id)
|
||||||
|
.content(event.content)
|
||||||
|
.author(author)
|
||||||
|
.created_at(event.created_at)
|
||||||
|
.replies_to(replies_to)
|
||||||
|
.mentions(mentions)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
cx.emit(Incoming(message));
|
cx.emit(Incoming(message));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates a temporary message for optimistic updates
|
/// Creates a temporary message for optimistic updates
|
||||||
///
|
///
|
||||||
@@ -499,22 +550,69 @@ impl Room {
|
|||||||
///
|
///
|
||||||
/// Returns `Some(Message)` containing the temporary message if the current user's profile is available,
|
/// Returns `Some(Message)` containing the temporary message if the current user's profile is available,
|
||||||
/// or `None` if no account is found.
|
/// or `None` if no account is found.
|
||||||
pub fn create_temp_message(&self, content: &str, cx: &App) -> Option<Message> {
|
pub fn create_temp_message(
|
||||||
let profile = Account::get_global(cx).profile.clone()?;
|
&self,
|
||||||
let public_key = profile.public_key();
|
content: &str,
|
||||||
|
replies: Option<&Vec<Message>>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<Message> {
|
||||||
|
let author = Account::get_global(cx).profile.clone()?;
|
||||||
|
let public_key = author.public_key();
|
||||||
let builder = EventBuilder::private_msg_rumor(public_key, content);
|
let builder = EventBuilder::private_msg_rumor(public_key, content);
|
||||||
|
|
||||||
|
// Add event reference if it's present (replying to another event)
|
||||||
|
let mut refs = vec![];
|
||||||
|
|
||||||
|
if let Some(replies) = replies {
|
||||||
|
if replies.len() == 1 {
|
||||||
|
refs.push(Tag::event(replies[0].id.unwrap()))
|
||||||
|
} else {
|
||||||
|
for message in replies.iter() {
|
||||||
|
refs.push(Tag::custom(TagKind::q(), vec![message.id.unwrap()]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut event = if !refs.is_empty() {
|
||||||
|
builder.tags(refs).build(public_key)
|
||||||
|
} else {
|
||||||
|
builder.build(public_key)
|
||||||
|
};
|
||||||
|
|
||||||
// Create a unsigned event to convert to Coop Message
|
// Create a unsigned event to convert to Coop Message
|
||||||
let mut event = builder.build(public_key);
|
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
|
|
||||||
// Extract all mentions from content
|
// Extract all mentions from content
|
||||||
let mentions = extract_mentions(&event.content, cx);
|
let mentions = extract_mentions(&event.content, cx);
|
||||||
|
|
||||||
Some(
|
// Extract reply_to if present
|
||||||
Message::new(event.id.unwrap(), event.content, profile, event.created_at)
|
let mut replies_to = vec![];
|
||||||
.with_mentions(mentions),
|
|
||||||
)
|
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()
|
||||||
|
.id(event.id.unwrap())
|
||||||
|
.content(event.content)
|
||||||
|
.author(author)
|
||||||
|
.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
|
||||||
@@ -528,8 +626,14 @@ impl Room {
|
|||||||
///
|
///
|
||||||
/// A Task that resolves to Result<Vec<String>, Error> where the
|
/// A Task that resolves to Result<Vec<String>, Error> where the
|
||||||
/// strings contain error messages for any failed sends
|
/// strings contain error messages for any failed sends
|
||||||
pub fn send_in_background(&self, msg: &str, cx: &App) -> Task<Result<Vec<SendError>, Error>> {
|
pub fn send_in_background(
|
||||||
let content = msg.to_owned();
|
&self,
|
||||||
|
content: &str,
|
||||||
|
replies: Option<&Vec<Message>>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<Vec<SendError>, Error>> {
|
||||||
|
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 = Arc::clone(&self.members);
|
let public_keys = Arc::clone(&self.members);
|
||||||
@@ -551,6 +655,17 @@ impl Room {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Add event reference if it's present (replying to another event)
|
||||||
|
if let Some(replies) = replies {
|
||||||
|
if replies.len() == 1 {
|
||||||
|
tags.push(Tag::event(replies[0].id.unwrap()))
|
||||||
|
} else {
|
||||||
|
for message in replies.iter() {
|
||||||
|
tags.push(Tag::custom(TagKind::q(), vec![message.id.unwrap()]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add subject tag if it's present
|
// Add subject tag if it's present
|
||||||
if let Some(subject) = subject {
|
if let Some(subject) = subject {
|
||||||
tags.push(Tag::from_standardized(TagStandard::Subject(
|
tags.push(Tag::from_standardized(TagStandard::Subject(
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use async_utility::task::spawn;
|
use async_utility::task::spawn;
|
||||||
use chats::{
|
use chats::{
|
||||||
message::{Message, RoomMessage},
|
message::Message,
|
||||||
room::Room,
|
room::{Room, SendError},
|
||||||
ChatRegistry,
|
ChatRegistry,
|
||||||
};
|
};
|
||||||
use common::{nip96_upload, profile::SharedProfile};
|
use common::{nip96_upload, profile::SharedProfile};
|
||||||
@@ -35,8 +35,6 @@ use ui::{
|
|||||||
|
|
||||||
use crate::views::subject;
|
use crate::views::subject;
|
||||||
|
|
||||||
const DESC: &str = "This conversation is private. Only members can see each other's messages.";
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||||
pub struct ChangeSubject(pub String);
|
pub struct ChangeSubject(pub String);
|
||||||
|
|
||||||
@@ -56,11 +54,12 @@ pub struct Chat {
|
|||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
// Chat Room
|
// Chat Room
|
||||||
room: Entity<Room>,
|
room: Entity<Room>,
|
||||||
messages: Entity<Vec<RoomMessage>>,
|
messages: Entity<Vec<Rc<RefCell<Message>>>>,
|
||||||
text_data: HashMap<EventId, RichText>,
|
text_data: HashMap<EventId, RichText>,
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
// New Message
|
// New Message
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
|
replies_to: Entity<Option<Vec<Message>>>,
|
||||||
// Media Attachment
|
// Media Attachment
|
||||||
attaches: Entity<Option<Vec<Url>>>,
|
attaches: Entity<Option<Vec<Url>>>,
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
@@ -70,17 +69,30 @@ pub struct Chat {
|
|||||||
|
|
||||||
impl Chat {
|
impl Chat {
|
||||||
pub fn new(id: &u64, room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
pub fn new(id: &u64, room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||||
let messages = cx.new(|_| vec![RoomMessage::announcement()]);
|
|
||||||
let attaches = cx.new(|_| None);
|
let attaches = cx.new(|_| None);
|
||||||
|
let replies_to = cx.new(|_| None);
|
||||||
|
|
||||||
|
let messages = cx.new(|_| {
|
||||||
|
let message = Message::builder()
|
||||||
|
.content(
|
||||||
|
"This conversation is private. Only members can see each other's messages."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
.build_rc()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
vec![message]
|
||||||
|
});
|
||||||
|
|
||||||
let input = cx.new(|cx| {
|
let input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
.placeholder("Message...")
|
.placeholder("Message...")
|
||||||
.multi_line()
|
.multi_line()
|
||||||
.prevent_new_line_on_enter()
|
.prevent_new_line_on_enter()
|
||||||
.rows(1)
|
.rows(1)
|
||||||
|
.max_rows(20)
|
||||||
.auto_grow()
|
.auto_grow()
|
||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
.max_rows(20)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
@@ -102,25 +114,13 @@ impl Chat {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe_in(&room, window, move |this, _, incoming, _w, cx| {
|
cx.subscribe_in(&room, window, move |this, _, incoming, _w, cx| {
|
||||||
let created_at = &incoming.0.created_at.to_string()[..5];
|
|
||||||
let content = incoming.0.content.as_str();
|
|
||||||
let author = incoming.0.author.public_key();
|
|
||||||
|
|
||||||
// Check if the incoming message is the same as the new message created by optimistic update
|
// Check if the incoming message is the same as the new message created by optimistic update
|
||||||
if this.messages.read(cx).iter().any(|msg| {
|
if this.prevent_duplicate_message(&incoming.0, cx) {
|
||||||
if let RoomMessage::User(m) = msg {
|
|
||||||
created_at == &m.created_at.to_string()[..5]
|
|
||||||
&& m.content == content
|
|
||||||
&& m.author.public_key() == author
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let old_len = this.messages.read(cx).len();
|
let old_len = this.messages.read(cx).len();
|
||||||
let message = RoomMessage::user(incoming.0.clone());
|
let message = incoming.0.clone().into_rc();
|
||||||
|
|
||||||
cx.update_entity(&this.messages, |this, cx| {
|
cx.update_entity(&this.messages, |this, cx| {
|
||||||
this.extend(vec![message]);
|
this.extend(vec![message]);
|
||||||
@@ -152,6 +152,7 @@ impl Chat {
|
|||||||
messages,
|
messages,
|
||||||
list_state,
|
list_state,
|
||||||
input,
|
input,
|
||||||
|
replies_to,
|
||||||
attaches,
|
attaches,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
}
|
}
|
||||||
@@ -161,18 +162,18 @@ impl Chat {
|
|||||||
/// Load all messages belonging to this room
|
/// Load all messages belonging to this room
|
||||||
pub(crate) fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
pub(crate) fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let room = self.room.read(cx);
|
let room = self.room.read(cx);
|
||||||
let task = room.load_messages(cx);
|
let load_messages = room.load_messages(cx);
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
match task.await {
|
match load_messages.await {
|
||||||
Ok(events) => {
|
Ok(messages) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
let old_len = this.messages.read(cx).len();
|
let old_len = this.messages.read(cx).len();
|
||||||
let new_len = events.len();
|
let new_len = messages.len();
|
||||||
|
|
||||||
// Extend the messages list with the new events
|
// Extend the messages list with the new events
|
||||||
this.messages.update(cx, |this, cx| {
|
this.messages.update(cx, |this, cx| {
|
||||||
this.extend(events);
|
this.extend(messages.into_iter().map(|e| e.into_rc()));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,21 +217,42 @@ impl Chat {
|
|||||||
content
|
content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context<Self>) -> bool {
|
||||||
|
let min_timestamp = new_msg.created_at.as_u64().saturating_sub(2);
|
||||||
|
|
||||||
|
self.messages.read(cx).iter().any(|existing| {
|
||||||
|
let existing = existing.borrow();
|
||||||
|
// Check if messages are within the time window
|
||||||
|
(existing.created_at.as_u64() >= min_timestamp) &&
|
||||||
|
// Compare content and author
|
||||||
|
(existing.content == new_msg.content) &&
|
||||||
|
(existing.author == new_msg.author)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.input.update(cx, |this, cx| {
|
self.input.update(cx, |this, cx| {
|
||||||
this.set_loading(true, cx);
|
this.set_loading(true, cx);
|
||||||
this.set_disabled(true, cx);
|
this.set_disabled(true, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get the message which includes all attachments
|
||||||
let content = self.message(cx);
|
let content = self.message(cx);
|
||||||
|
// Get replies_to if it's present
|
||||||
|
let replies = self.replies_to.read(cx).as_ref();
|
||||||
|
// Get the current room entity
|
||||||
let room = self.room.read(cx);
|
let room = self.room.read(cx);
|
||||||
let temp_message = room.create_temp_message(&content, cx);
|
// Create a temporary message for optimistic update
|
||||||
let send_message = room.send_in_background(&content, cx);
|
let temp_message = room.create_temp_message(&content, replies, cx);
|
||||||
|
// Create a task for sending the message in the background
|
||||||
|
let send_message = room.send_in_background(&content, replies, cx);
|
||||||
|
|
||||||
if let Some(message) = temp_message {
|
if let Some(message) = temp_message {
|
||||||
let id = message.id;
|
let id = message.id;
|
||||||
// Optimistically update message list
|
// Optimistically update message list
|
||||||
self.push_user_message(message, cx);
|
self.insert_message(message, cx);
|
||||||
|
// Remove all replies
|
||||||
|
self.remove_all_replies(cx);
|
||||||
|
|
||||||
// Reset the input state
|
// Reset the input state
|
||||||
self.input.update(cx, |this, cx| {
|
self.input.update(cx, |this, cx| {
|
||||||
@@ -245,16 +267,10 @@ impl Chat {
|
|||||||
if !reports.is_empty() {
|
if !reports.is_empty() {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.messages.update(cx, |this, cx| {
|
this.messages.update(cx, |this, cx| {
|
||||||
if let Some(msg) = this.iter_mut().find(|msg| {
|
if let Some(msg) = id.and_then(|id| {
|
||||||
if let RoomMessage::User(m) = msg {
|
this.iter().find(|msg| msg.borrow().id == Some(id)).cloned()
|
||||||
m.id == id
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}) {
|
}) {
|
||||||
if let RoomMessage::User(this) = msg {
|
msg.borrow_mut().errors = Some(reports);
|
||||||
this.errors = Some(reports)
|
|
||||||
}
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -267,9 +283,9 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_user_message(&self, message: Message, cx: &mut Context<Self>) {
|
fn insert_message(&self, message: Message, cx: &mut Context<Self>) {
|
||||||
let old_len = self.messages.read(cx).len();
|
let old_len = self.messages.read(cx).len();
|
||||||
let message = RoomMessage::user(message);
|
let message = message.into_rc();
|
||||||
|
|
||||||
cx.update_entity(&self.messages, |this, cx| {
|
cx.update_entity(&self.messages, |this, cx| {
|
||||||
this.extend(vec![message]);
|
this.extend(vec![message]);
|
||||||
@@ -279,17 +295,44 @@ impl Chat {
|
|||||||
self.list_state.splice(old_len..old_len, 1);
|
self.list_state.splice(old_len..old_len, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
fn scroll_to(&self, id: EventId, cx: &Context<Self>) {
|
||||||
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
|
if let Some(ix) = self
|
||||||
let old_len = self.messages.read(cx).len();
|
.messages
|
||||||
let message = RoomMessage::system(content.into());
|
.read(cx)
|
||||||
|
.iter()
|
||||||
|
.position(|m| m.borrow().id == Some(id))
|
||||||
|
{
|
||||||
|
self.list_state.scroll_to_reveal_item(ix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cx.update_entity(&self.messages, |this, cx| {
|
fn reply(&mut self, message: Message, cx: &mut Context<Self>) {
|
||||||
this.extend(vec![message]);
|
self.replies_to.update(cx, |this, cx| {
|
||||||
|
if let Some(replies) = this {
|
||||||
|
replies.push(message);
|
||||||
|
} else {
|
||||||
|
*this = Some(vec![message])
|
||||||
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
self.list_state.splice(old_len..old_len, 1);
|
fn remove_reply(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||||
|
self.replies_to.update(cx, |this, cx| {
|
||||||
|
if let Some(replies) = this {
|
||||||
|
if let Some(ix) = replies.iter().position(|m| m.id == Some(id)) {
|
||||||
|
replies.remove(ix);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_all_replies(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.replies_to.update(cx, |this, cx| {
|
||||||
|
*this = None;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -380,6 +423,90 @@ impl Chat {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_attach(&mut self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
|
||||||
|
let url = url.clone();
|
||||||
|
let path: SharedString = url.to_string().into();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(path.clone())
|
||||||
|
.relative()
|
||||||
|
.w_16()
|
||||||
|
.child(
|
||||||
|
img(format!(
|
||||||
|
"{}/?url={}&w=128&h=128&fit=cover&n=-1",
|
||||||
|
IMAGE_SERVICE, path
|
||||||
|
))
|
||||||
|
.size_16()
|
||||||
|
.shadow_lg()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.object_fit(ObjectFit::ScaleDown),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top_neg_2()
|
||||||
|
.right_neg_2()
|
||||||
|
.size_4()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded_full()
|
||||||
|
.bg(red())
|
||||||
|
.child(Icon::new(IconName::Close).size_2().text_color(white())),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.remove_media(&url, window, cx);
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_reply(&mut self, message: &Message, cx: &Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.pl_2()
|
||||||
|
.border_l_2()
|
||||||
|
.border_color(cx.theme().element_active)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_baseline()
|
||||||
|
.gap_1()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Replying to:")
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_accent)
|
||||||
|
.child(message.author.as_ref().unwrap().shared_name()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("remove-reply")
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.on_click({
|
||||||
|
let id = message.id.unwrap();
|
||||||
|
cx.listener(move |this, _, _, cx| {
|
||||||
|
this.remove_reply(id, cx);
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.text_sm()
|
||||||
|
.text_ellipsis()
|
||||||
|
.line_clamp(1)
|
||||||
|
.child(message.content.clone()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn render_message(
|
fn render_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
@@ -387,136 +514,15 @@ impl Chat {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let Some(message) = self.messages.read(cx).get(ix) else {
|
let Some(message) = self.messages.read(cx).get(ix) else {
|
||||||
return div().into_element();
|
return div().id(ix);
|
||||||
};
|
};
|
||||||
|
|
||||||
match message {
|
let message = message.borrow();
|
||||||
RoomMessage::User(item) => self.render_user_msg(item, window, cx),
|
|
||||||
RoomMessage::System(content) => self.render_system_msg(content, cx),
|
|
||||||
RoomMessage::Announcement => self.render_announcement_msg(cx),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_user_msg(&mut self, item: &Message, window: &mut Window, cx: &Context<Self>) -> Div {
|
// Message without ID, Author probably the placeholder
|
||||||
let texts = self
|
let (Some(id), Some(author)) = (message.id, message.author.as_ref()) else {
|
||||||
.text_data
|
return div()
|
||||||
.entry(item.id)
|
.id(ix)
|
||||||
.or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions));
|
|
||||||
|
|
||||||
div()
|
|
||||||
.group("")
|
|
||||||
.w_full()
|
|
||||||
.relative()
|
|
||||||
.flex()
|
|
||||||
.gap_3()
|
|
||||||
.px_3()
|
|
||||||
.py_2()
|
|
||||||
.hover(|this| this.bg(cx.theme().surface_background))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.left_0()
|
|
||||||
.top_0()
|
|
||||||
.w(px(2.))
|
|
||||||
.h_full()
|
|
||||||
.bg(cx.theme().border_transparent)
|
|
||||||
.group_hover("", |this| this.bg(cx.theme().element_active)),
|
|
||||||
)
|
|
||||||
.child(img(item.author.shared_avatar()).size_8().flex_shrink_0())
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.flex_initial()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.items_baseline()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text)
|
|
||||||
.child(item.author.shared_name()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.child(item.ago()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(texts.element("body".into(), window, cx))
|
|
||||||
.when_some(item.errors.clone(), |this, errors| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.id("")
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.gap_1()
|
|
||||||
.text_color(gpui::red())
|
|
||||||
.text_xs()
|
|
||||||
.italic()
|
|
||||||
.child(Icon::new(IconName::Info).small())
|
|
||||||
.child("Failed to send message. Click to see details.")
|
|
||||||
.on_click(move |_, window, cx| {
|
|
||||||
let errors = errors.clone();
|
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, cx| {
|
|
||||||
this.title("Error Logs").child(
|
|
||||||
div().flex().flex_col().gap_2().px_3().pb_3().children(
|
|
||||||
errors.clone().into_iter().map(|error| {
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.items_baseline()
|
|
||||||
.gap_1()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("Send to:")
|
|
||||||
.child(error.profile.shared_name()),
|
|
||||||
)
|
|
||||||
.child(error.message)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_system_msg(&mut self, content: &SharedString, cx: &Context<Self>) -> Div {
|
|
||||||
div()
|
|
||||||
.group("")
|
|
||||||
.w_full()
|
|
||||||
.relative()
|
|
||||||
.flex()
|
|
||||||
.gap_3()
|
|
||||||
.px_3()
|
|
||||||
.py_2()
|
|
||||||
.items_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.left_0()
|
|
||||||
.top_0()
|
|
||||||
.w(px(2.))
|
|
||||||
.h_full()
|
|
||||||
.bg(cx.theme().border_transparent)
|
|
||||||
.group_hover("", |this| this.bg(red())),
|
|
||||||
)
|
|
||||||
.child(img("brand/avatar.png").size_8().flex_shrink_0())
|
|
||||||
.text_sm()
|
|
||||||
.text_color(red())
|
|
||||||
.child(content.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_announcement_msg(&mut self, cx: &Context<Self>) -> Div {
|
|
||||||
div()
|
|
||||||
.group("")
|
.group("")
|
||||||
.w_full()
|
.w_full()
|
||||||
.relative()
|
.relative()
|
||||||
@@ -540,7 +546,150 @@ impl Chat {
|
|||||||
.size_10()
|
.size_10()
|
||||||
.text_color(cx.theme().elevated_surface_background),
|
.text_color(cx.theme().elevated_surface_background),
|
||||||
)
|
)
|
||||||
.child(DESC)
|
.child(message.content.clone());
|
||||||
|
};
|
||||||
|
|
||||||
|
let texts = self
|
||||||
|
.text_data
|
||||||
|
.entry(id)
|
||||||
|
.or_insert_with(|| RichText::new(message.content.to_string(), &message.mentions));
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(ix)
|
||||||
|
.group("")
|
||||||
|
.relative()
|
||||||
|
.w_full()
|
||||||
|
.py_1()
|
||||||
|
.px_3()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(img(author.shared_avatar()).size_8().flex_shrink_0())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_initial()
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_baseline()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.child(author.shared_name()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child(message.ago()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when_some(message.replies_to.as_ref(), |this, replies| {
|
||||||
|
this.w_full().children({
|
||||||
|
let mut items = vec![];
|
||||||
|
|
||||||
|
for (ix, id) in replies.iter().enumerate() {
|
||||||
|
if let Some(message) = self
|
||||||
|
.messages
|
||||||
|
.read(cx)
|
||||||
|
.iter()
|
||||||
|
.find(|msg| msg.borrow().id == Some(*id))
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
let message = message.borrow();
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
div()
|
||||||
|
.id(ix)
|
||||||
|
.w_full()
|
||||||
|
.px_2()
|
||||||
|
.border_l_2()
|
||||||
|
.border_color(cx.theme().element_selected)
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_accent)
|
||||||
|
.child(
|
||||||
|
message
|
||||||
|
.author
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.shared_name(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.text_ellipsis()
|
||||||
|
.line_clamp(1)
|
||||||
|
.child(message.content.clone()),
|
||||||
|
)
|
||||||
|
.hover(|this| {
|
||||||
|
this.bg(cx
|
||||||
|
.theme()
|
||||||
|
.elevated_surface_background)
|
||||||
|
})
|
||||||
|
.on_click({
|
||||||
|
let id = message.id.unwrap();
|
||||||
|
cx.listener(move |this, _, _, cx| {
|
||||||
|
this.scroll_to(id, cx)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.child(texts.element("body".into(), window, cx))
|
||||||
|
.when_some(message.errors.clone(), |this, errors| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.id("")
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(gpui::red())
|
||||||
|
.text_xs()
|
||||||
|
.italic()
|
||||||
|
.child(Icon::new(IconName::Info).small())
|
||||||
|
.child("Failed to send message. Click to see details.")
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
let errors = errors.clone();
|
||||||
|
|
||||||
|
window.open_modal(cx, move |this, _window, cx| {
|
||||||
|
this.title("Error Logs")
|
||||||
|
.child(message_errors(errors.clone(), cx))
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(message_border(cx))
|
||||||
|
.child(message_actions(
|
||||||
|
vec![Button::new("reply")
|
||||||
|
.icon(IconName::Reply)
|
||||||
|
.tooltip("Reply")
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.on_click({
|
||||||
|
let message = message.clone();
|
||||||
|
cx.listener(move |this, _, _, cx| {
|
||||||
|
this.reply(message.clone(), cx);
|
||||||
|
})
|
||||||
|
})],
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.hover(|this| this.bg(cx.theme().surface_background))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,50 +756,30 @@ impl Render for Chat {
|
|||||||
.size_full()
|
.size_full()
|
||||||
.child(list(self.list_state.clone()).flex_1())
|
.child(list(self.list_state.clone()).flex_1())
|
||||||
.child(
|
.child(
|
||||||
div().flex_shrink_0().px_3().py_2().child(
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.w_full()
|
||||||
|
.relative()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.when_some(self.attaches.read(cx).as_ref(), |this, attaches| {
|
.when_some(self.attaches.read(cx).as_ref(), |this, urls| {
|
||||||
this.gap_1p5().children(attaches.iter().map(|url| {
|
this.gap_1p5()
|
||||||
let url = url.clone();
|
.children(urls.iter().map(|url| self.render_attach(url, cx)))
|
||||||
let path: SharedString = url.to_string().into();
|
})
|
||||||
|
.when_some(self.replies_to.read(cx).as_ref(), |this, messages| {
|
||||||
|
this.gap_1p5().children({
|
||||||
|
let mut items = vec![];
|
||||||
|
|
||||||
div()
|
for message in messages.iter() {
|
||||||
.id(path.clone())
|
items.push(self.render_reply(message, cx));
|
||||||
.relative()
|
}
|
||||||
.w_16()
|
|
||||||
.child(
|
items
|
||||||
img(format!(
|
})
|
||||||
"{}/?url={}&w=128&h=128&fit=cover&n=-1",
|
|
||||||
IMAGE_SERVICE, path
|
|
||||||
))
|
|
||||||
.size_16()
|
|
||||||
.shadow_lg()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.object_fit(ObjectFit::ScaleDown),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.top_neg_2()
|
|
||||||
.right_neg_2()
|
|
||||||
.size_4()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.rounded_full()
|
|
||||||
.bg(red())
|
|
||||||
.child(
|
|
||||||
Icon::new(IconName::Close)
|
|
||||||
.size_2()
|
|
||||||
.text_color(white()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.remove_media(&url, window, cx);
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -687,3 +816,55 @@ impl Render for Chat {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn message_border(cx: &App) -> Div {
|
||||||
|
div()
|
||||||
|
.group_hover("", |this| this.bg(cx.theme().element_active))
|
||||||
|
.absolute()
|
||||||
|
.left_0()
|
||||||
|
.top_0()
|
||||||
|
.w(px(2.))
|
||||||
|
.h_full()
|
||||||
|
.bg(cx.theme().border_transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_errors(errors: Vec<SendError>, cx: &App) -> Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.px_3()
|
||||||
|
.pb_3()
|
||||||
|
.children(errors.into_iter().map(|error| {
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_baseline()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Send to:")
|
||||||
|
.child(error.profile.shared_name()),
|
||||||
|
)
|
||||||
|
.child(error.message)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_actions(buttons: Vec<Button>, cx: &App) -> Div {
|
||||||
|
div()
|
||||||
|
.group_hover("", |this| this.visible())
|
||||||
|
.invisible()
|
||||||
|
.absolute()
|
||||||
|
.right_4()
|
||||||
|
.top_neg_2()
|
||||||
|
.shadow_sm()
|
||||||
|
.rounded_md()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().background)
|
||||||
|
.p_0p5()
|
||||||
|
.flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.children(buttons)
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ impl ThemeColor {
|
|||||||
element_background: brand().light().step_9(),
|
element_background: brand().light().step_9(),
|
||||||
element_hover: brand().light_alpha().step_10(),
|
element_hover: brand().light_alpha().step_10(),
|
||||||
element_active: brand().light().step_10(),
|
element_active: brand().light().step_10(),
|
||||||
element_selected: brand().light().step_10(),
|
element_selected: brand().light().step_11(),
|
||||||
element_disabled: brand().light_alpha().step_3(),
|
element_disabled: brand().light_alpha().step_3(),
|
||||||
drop_target_background: brand().light_alpha().step_2(),
|
drop_target_background: brand().light_alpha().step_2(),
|
||||||
ghost_element_background: gpui::transparent_black(),
|
ghost_element_background: gpui::transparent_black(),
|
||||||
@@ -209,7 +209,7 @@ impl ThemeColor {
|
|||||||
element_background: brand().dark().step_9(),
|
element_background: brand().dark().step_9(),
|
||||||
element_hover: brand().dark_alpha().step_10(),
|
element_hover: brand().dark_alpha().step_10(),
|
||||||
element_active: brand().dark().step_10(),
|
element_active: brand().dark().step_10(),
|
||||||
element_selected: brand().dark().step_10(),
|
element_selected: brand().dark().step_11(),
|
||||||
element_disabled: brand().dark_alpha().step_3(),
|
element_disabled: brand().dark_alpha().step_3(),
|
||||||
drop_target_background: brand().dark_alpha().step_2(),
|
drop_target_background: brand().dark_alpha().step_2(),
|
||||||
ghost_element_background: gpui::transparent_black(),
|
ghost_element_background: gpui::transparent_black(),
|
||||||
|
|||||||
@@ -40,11 +40,7 @@ pub enum IconName {
|
|||||||
Inbox,
|
Inbox,
|
||||||
Info,
|
Info,
|
||||||
Loader,
|
Loader,
|
||||||
LoaderCircle,
|
|
||||||
MailboxFill,
|
|
||||||
Menu,
|
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
|
||||||
PanelBottom,
|
PanelBottom,
|
||||||
PanelBottomOpen,
|
PanelBottomOpen,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
@@ -58,6 +54,8 @@ pub enum IconName {
|
|||||||
PlusCircleFill,
|
PlusCircleFill,
|
||||||
Relays,
|
Relays,
|
||||||
ResizeCorner,
|
ResizeCorner,
|
||||||
|
Reply,
|
||||||
|
Forward,
|
||||||
Search,
|
Search,
|
||||||
SearchFill,
|
SearchFill,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -110,11 +108,7 @@ impl IconName {
|
|||||||
Self::Inbox => "icons/inbox.svg",
|
Self::Inbox => "icons/inbox.svg",
|
||||||
Self::Info => "icons/info.svg",
|
Self::Info => "icons/info.svg",
|
||||||
Self::Loader => "icons/loader.svg",
|
Self::Loader => "icons/loader.svg",
|
||||||
Self::LoaderCircle => "icons/loader-circle.svg",
|
|
||||||
Self::MailboxFill => "icons/mailbox-fill.svg",
|
|
||||||
Self::Menu => "icons/menu.svg",
|
|
||||||
Self::Moon => "icons/moon.svg",
|
Self::Moon => "icons/moon.svg",
|
||||||
Self::Palette => "icons/palette.svg",
|
|
||||||
Self::PanelBottom => "icons/panel-bottom.svg",
|
Self::PanelBottom => "icons/panel-bottom.svg",
|
||||||
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
||||||
Self::PanelLeft => "icons/panel-left.svg",
|
Self::PanelLeft => "icons/panel-left.svg",
|
||||||
@@ -128,6 +122,8 @@ impl IconName {
|
|||||||
Self::PlusCircleFill => "icons/plus-circle-fill.svg",
|
Self::PlusCircleFill => "icons/plus-circle-fill.svg",
|
||||||
Self::Relays => "icons/relays.svg",
|
Self::Relays => "icons/relays.svg",
|
||||||
Self::ResizeCorner => "icons/resize-corner.svg",
|
Self::ResizeCorner => "icons/resize-corner.svg",
|
||||||
|
Self::Reply => "icons/reply.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::Settings => "icons/settings.svg",
|
Self::Settings => "icons/settings.svg",
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ impl Render for Tooltip {
|
|||||||
.p_2()
|
.p_2()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.bg(cx.theme().background)
|
.bg(cx.theme().surface_background)
|
||||||
.shadow_md()
|
.shadow_md()
|
||||||
.rounded_lg()
|
.rounded_lg()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(cx.theme().text)
|
.text_color(cx.theme().text_muted)
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(self.text.clone()),
|
.child(self.text.clone()),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user