From 3422eb65e81394329385a44223638fed5ea23f2a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 7 Sep 2023 11:16:51 -0700 Subject: [PATCH 01/19] Restore channel chat model and panel view --- Cargo.lock | 1 + crates/channel/src/channel.rs | 9 +- crates/channel/src/channel_buffer.rs | 14 +- crates/channel/src/channel_chat.rs | 459 ++++++++++++++++++++++ crates/channel/src/channel_store.rs | 88 +++-- crates/channel/src/channel_store_tests.rs | 220 ++++++++++- crates/collab/src/tests/test_server.rs | 2 +- crates/collab_ui/Cargo.toml | 1 + crates/collab_ui/src/channel_view.rs | 11 +- crates/collab_ui/src/chat_panel.rs | 391 ++++++++++++++++++ crates/collab_ui/src/collab_ui.rs | 1 + crates/rpc/proto/zed.proto | 57 ++- crates/rpc/src/proto.rs | 12 + crates/theme/src/theme.rs | 12 + 14 files changed, 1227 insertions(+), 51 deletions(-) create mode 100644 crates/channel/src/channel_chat.rs create mode 100644 crates/collab_ui/src/chat_panel.rs diff --git a/Cargo.lock b/Cargo.lock index 121e9a28dd160e9eef6d4d7497c7876196a57c88..6b25b47b75a400fbce6a480eec6626777859e263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,6 +1553,7 @@ dependencies = [ "settings", "theme", "theme_selector", + "time 0.3.27", "util", "vcs_menu", "workspace", diff --git a/crates/channel/src/channel.rs b/crates/channel/src/channel.rs index 15631b7dd312f36126ec1e13b2413fc01e5ca8af..b4acc0c25fcb8587dc7b507ced5e78156fd94c45 100644 --- a/crates/channel/src/channel.rs +++ b/crates/channel/src/channel.rs @@ -1,10 +1,13 @@ +mod channel_buffer; +mod channel_chat; mod channel_store; -pub mod channel_buffer; -use std::sync::Arc; +pub use channel_buffer::{ChannelBuffer, ChannelBufferEvent}; +pub use channel_chat::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId}; +pub use channel_store::{Channel, ChannelEvent, ChannelId, ChannelMembership, ChannelStore}; -pub use channel_store::*; use client::Client; +use std::sync::Arc; #[cfg(test)] mod channel_store_tests; diff --git a/crates/channel/src/channel_buffer.rs b/crates/channel/src/channel_buffer.rs index e11282cf7963a9ba4f34c7530a6e8267fbe35274..06f9093fb5f9c628ba3ec2320f3605480f65486c 100644 --- a/crates/channel/src/channel_buffer.rs +++ b/crates/channel/src/channel_buffer.rs @@ -23,13 +23,13 @@ pub struct ChannelBuffer { subscription: Option, } -pub enum Event { +pub enum ChannelBufferEvent { CollaboratorsChanged, Disconnected, } impl Entity for ChannelBuffer { - type Event = Event; + type Event = ChannelBufferEvent; fn release(&mut self, _: &mut AppContext) { if self.connected { @@ -101,7 +101,7 @@ impl ChannelBuffer { } } self.collaborators = collaborators; - cx.emit(Event::CollaboratorsChanged); + cx.emit(ChannelBufferEvent::CollaboratorsChanged); cx.notify(); } @@ -141,7 +141,7 @@ impl ChannelBuffer { this.update(&mut cx, |this, cx| { this.collaborators.push(collaborator); - cx.emit(Event::CollaboratorsChanged); + cx.emit(ChannelBufferEvent::CollaboratorsChanged); cx.notify(); }); @@ -165,7 +165,7 @@ impl ChannelBuffer { true } }); - cx.emit(Event::CollaboratorsChanged); + cx.emit(ChannelBufferEvent::CollaboratorsChanged); cx.notify(); }); @@ -185,7 +185,7 @@ impl ChannelBuffer { break; } } - cx.emit(Event::CollaboratorsChanged); + cx.emit(ChannelBufferEvent::CollaboratorsChanged); cx.notify(); }); @@ -230,7 +230,7 @@ impl ChannelBuffer { if self.connected { self.connected = false; self.subscription.take(); - cx.emit(Event::Disconnected); + cx.emit(ChannelBufferEvent::Disconnected); cx.notify() } } diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs new file mode 100644 index 0000000000000000000000000000000000000000..ebe491e5b896ac1b638237b0ad0df113a2e923b3 --- /dev/null +++ b/crates/channel/src/channel_chat.rs @@ -0,0 +1,459 @@ +use crate::Channel; +use anyhow::{anyhow, Result}; +use client::{ + proto, + user::{User, UserStore}, + Client, Subscription, TypedEnvelope, +}; +use futures::lock::Mutex; +use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; +use rand::prelude::*; +use std::{collections::HashSet, mem, ops::Range, sync::Arc}; +use sum_tree::{Bias, SumTree}; +use time::OffsetDateTime; +use util::{post_inc, ResultExt as _, TryFutureExt}; + +pub struct ChannelChat { + channel: Arc, + messages: SumTree, + loaded_all_messages: bool, + next_pending_message_id: usize, + user_store: ModelHandle, + rpc: Arc, + outgoing_messages_lock: Arc>, + rng: StdRng, + _subscription: Subscription, +} + +#[derive(Clone, Debug)] +pub struct ChannelMessage { + pub id: ChannelMessageId, + pub body: String, + pub timestamp: OffsetDateTime, + pub sender: Arc, + pub nonce: u128, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum ChannelMessageId { + Saved(u64), + Pending(usize), +} + +#[derive(Clone, Debug, Default)] +pub struct ChannelMessageSummary { + max_id: ChannelMessageId, + count: usize, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct Count(usize); + +#[derive(Clone, Debug, PartialEq)] +pub enum ChannelChatEvent { + MessagesUpdated { + old_range: Range, + new_count: usize, + }, +} + +impl Entity for ChannelChat { + type Event = ChannelChatEvent; + + fn release(&mut self, _: &mut AppContext) { + self.rpc + .send(proto::LeaveChannelChat { + channel_id: self.channel.id, + }) + .log_err(); + } +} + +impl ChannelChat { + pub fn init(rpc: &Arc) { + rpc.add_model_message_handler(Self::handle_message_sent); + } + + pub async fn new( + channel: Arc, + user_store: ModelHandle, + client: Arc, + mut cx: AsyncAppContext, + ) -> Result> { + let channel_id = channel.id; + let subscription = client.subscribe_to_entity(channel_id).unwrap(); + + let response = client + .request(proto::JoinChannelChat { channel_id }) + .await?; + let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?; + let loaded_all_messages = response.done; + + Ok(cx.add_model(|cx| { + let mut this = Self { + channel, + user_store, + rpc: client, + outgoing_messages_lock: Default::default(), + messages: Default::default(), + loaded_all_messages, + next_pending_message_id: 0, + rng: StdRng::from_entropy(), + _subscription: subscription.set_model(&cx.handle(), &mut cx.to_async()), + }; + this.insert_messages(messages, cx); + this + })) + } + + pub fn name(&self) -> &str { + &self.channel.name + } + + pub fn send_message( + &mut self, + body: String, + cx: &mut ModelContext, + ) -> Result>> { + if body.is_empty() { + Err(anyhow!("message body can't be empty"))?; + } + + let current_user = self + .user_store + .read(cx) + .current_user() + .ok_or_else(|| anyhow!("current_user is not present"))?; + + let channel_id = self.channel.id; + let pending_id = ChannelMessageId::Pending(post_inc(&mut self.next_pending_message_id)); + let nonce = self.rng.gen(); + self.insert_messages( + SumTree::from_item( + ChannelMessage { + id: pending_id, + body: body.clone(), + sender: current_user, + timestamp: OffsetDateTime::now_utc(), + nonce, + }, + &(), + ), + cx, + ); + let user_store = self.user_store.clone(); + let rpc = self.rpc.clone(); + let outgoing_messages_lock = self.outgoing_messages_lock.clone(); + Ok(cx.spawn(|this, mut cx| async move { + let outgoing_message_guard = outgoing_messages_lock.lock().await; + let request = rpc.request(proto::SendChannelMessage { + channel_id, + body, + nonce: Some(nonce.into()), + }); + let response = request.await?; + drop(outgoing_message_guard); + let message = ChannelMessage::from_proto( + response.message.ok_or_else(|| anyhow!("invalid message"))?, + &user_store, + &mut cx, + ) + .await?; + this.update(&mut cx, |this, cx| { + this.insert_messages(SumTree::from_item(message, &()), cx); + Ok(()) + }) + })) + } + + pub fn load_more_messages(&mut self, cx: &mut ModelContext) -> bool { + if !self.loaded_all_messages { + let rpc = self.rpc.clone(); + let user_store = self.user_store.clone(); + let channel_id = self.channel.id; + if let Some(before_message_id) = + self.messages.first().and_then(|message| match message.id { + ChannelMessageId::Saved(id) => Some(id), + ChannelMessageId::Pending(_) => None, + }) + { + cx.spawn(|this, mut cx| { + async move { + let response = rpc + .request(proto::GetChannelMessages { + channel_id, + before_message_id, + }) + .await?; + let loaded_all_messages = response.done; + let messages = + messages_from_proto(response.messages, &user_store, &mut cx).await?; + this.update(&mut cx, |this, cx| { + this.loaded_all_messages = loaded_all_messages; + this.insert_messages(messages, cx); + }); + anyhow::Ok(()) + } + .log_err() + }) + .detach(); + return true; + } + } + false + } + + pub fn rejoin(&mut self, cx: &mut ModelContext) { + let user_store = self.user_store.clone(); + let rpc = self.rpc.clone(); + let channel_id = self.channel.id; + cx.spawn(|this, mut cx| { + async move { + let response = rpc.request(proto::JoinChannelChat { channel_id }).await?; + let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?; + let loaded_all_messages = response.done; + + let pending_messages = this.update(&mut cx, |this, cx| { + if let Some((first_new_message, last_old_message)) = + messages.first().zip(this.messages.last()) + { + if first_new_message.id > last_old_message.id { + let old_messages = mem::take(&mut this.messages); + cx.emit(ChannelChatEvent::MessagesUpdated { + old_range: 0..old_messages.summary().count, + new_count: 0, + }); + this.loaded_all_messages = loaded_all_messages; + } + } + + this.insert_messages(messages, cx); + if loaded_all_messages { + this.loaded_all_messages = loaded_all_messages; + } + + this.pending_messages().cloned().collect::>() + }); + + for pending_message in pending_messages { + let request = rpc.request(proto::SendChannelMessage { + channel_id, + body: pending_message.body, + nonce: Some(pending_message.nonce.into()), + }); + let response = request.await?; + let message = ChannelMessage::from_proto( + response.message.ok_or_else(|| anyhow!("invalid message"))?, + &user_store, + &mut cx, + ) + .await?; + this.update(&mut cx, |this, cx| { + this.insert_messages(SumTree::from_item(message, &()), cx); + }); + } + + anyhow::Ok(()) + } + .log_err() + }) + .detach(); + } + + pub fn message_count(&self) -> usize { + self.messages.summary().count + } + + pub fn messages(&self) -> &SumTree { + &self.messages + } + + pub fn message(&self, ix: usize) -> &ChannelMessage { + let mut cursor = self.messages.cursor::(); + cursor.seek(&Count(ix), Bias::Right, &()); + cursor.item().unwrap() + } + + pub fn messages_in_range(&self, range: Range) -> impl Iterator { + let mut cursor = self.messages.cursor::(); + cursor.seek(&Count(range.start), Bias::Right, &()); + cursor.take(range.len()) + } + + pub fn pending_messages(&self) -> impl Iterator { + let mut cursor = self.messages.cursor::(); + cursor.seek(&ChannelMessageId::Pending(0), Bias::Left, &()); + cursor + } + + async fn handle_message_sent( + this: ModelHandle, + message: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + let user_store = this.read_with(&cx, |this, _| this.user_store.clone()); + let message = message + .payload + .message + .ok_or_else(|| anyhow!("empty message"))?; + + let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?; + this.update(&mut cx, |this, cx| { + this.insert_messages(SumTree::from_item(message, &()), cx) + }); + + Ok(()) + } + + fn insert_messages(&mut self, messages: SumTree, cx: &mut ModelContext) { + if let Some((first_message, last_message)) = messages.first().zip(messages.last()) { + let nonces = messages + .cursor::<()>() + .map(|m| m.nonce) + .collect::>(); + + let mut old_cursor = self.messages.cursor::<(ChannelMessageId, Count)>(); + let mut new_messages = old_cursor.slice(&first_message.id, Bias::Left, &()); + let start_ix = old_cursor.start().1 .0; + let removed_messages = old_cursor.slice(&last_message.id, Bias::Right, &()); + let removed_count = removed_messages.summary().count; + let new_count = messages.summary().count; + let end_ix = start_ix + removed_count; + + new_messages.append(messages, &()); + + let mut ranges = Vec::>::new(); + if new_messages.last().unwrap().is_pending() { + new_messages.append(old_cursor.suffix(&()), &()); + } else { + new_messages.append( + old_cursor.slice(&ChannelMessageId::Pending(0), Bias::Left, &()), + &(), + ); + + while let Some(message) = old_cursor.item() { + let message_ix = old_cursor.start().1 .0; + if nonces.contains(&message.nonce) { + if ranges.last().map_or(false, |r| r.end == message_ix) { + ranges.last_mut().unwrap().end += 1; + } else { + ranges.push(message_ix..message_ix + 1); + } + } else { + new_messages.push(message.clone(), &()); + } + old_cursor.next(&()); + } + } + + drop(old_cursor); + self.messages = new_messages; + + for range in ranges.into_iter().rev() { + cx.emit(ChannelChatEvent::MessagesUpdated { + old_range: range, + new_count: 0, + }); + } + cx.emit(ChannelChatEvent::MessagesUpdated { + old_range: start_ix..end_ix, + new_count, + }); + cx.notify(); + } + } +} + +async fn messages_from_proto( + proto_messages: Vec, + user_store: &ModelHandle, + cx: &mut AsyncAppContext, +) -> Result> { + let unique_user_ids = proto_messages + .iter() + .map(|m| m.sender_id) + .collect::>() + .into_iter() + .collect(); + user_store + .update(cx, |user_store, cx| { + user_store.get_users(unique_user_ids, cx) + }) + .await?; + + let mut messages = Vec::with_capacity(proto_messages.len()); + for message in proto_messages { + messages.push(ChannelMessage::from_proto(message, user_store, cx).await?); + } + let mut result = SumTree::new(); + result.extend(messages, &()); + Ok(result) +} + +impl ChannelMessage { + pub async fn from_proto( + message: proto::ChannelMessage, + user_store: &ModelHandle, + cx: &mut AsyncAppContext, + ) -> Result { + let sender = user_store + .update(cx, |user_store, cx| { + user_store.get_user(message.sender_id, cx) + }) + .await?; + Ok(ChannelMessage { + id: ChannelMessageId::Saved(message.id), + body: message.body, + timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64)?, + sender, + nonce: message + .nonce + .ok_or_else(|| anyhow!("nonce is required"))? + .into(), + }) + } + + pub fn is_pending(&self) -> bool { + matches!(self.id, ChannelMessageId::Pending(_)) + } +} + +impl sum_tree::Item for ChannelMessage { + type Summary = ChannelMessageSummary; + + fn summary(&self) -> Self::Summary { + ChannelMessageSummary { + max_id: self.id, + count: 1, + } + } +} + +impl Default for ChannelMessageId { + fn default() -> Self { + Self::Saved(0) + } +} + +impl sum_tree::Summary for ChannelMessageSummary { + type Context = (); + + fn add_summary(&mut self, summary: &Self, _: &()) { + self.max_id = summary.max_id; + self.count += summary.count; + } +} + +impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for ChannelMessageId { + fn add_summary(&mut self, summary: &'a ChannelMessageSummary, _: &()) { + debug_assert!(summary.max_id > *self); + *self = summary.max_id; + } +} + +impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for Count { + fn add_summary(&mut self, summary: &'a ChannelMessageSummary, _: &()) { + self.0 += summary.count; + } +} diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index a4c8da6df4f594553ac3629a4d4fc4a1176d89a3..6096692ccba946eb77201750b0cb3a024e1f3b7f 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -1,4 +1,4 @@ -use crate::channel_buffer::ChannelBuffer; +use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat}; use anyhow::{anyhow, Result}; use client::{Client, Subscription, User, UserId, UserStore}; use collections::{hash_map, HashMap, HashSet}; @@ -20,7 +20,8 @@ pub struct ChannelStore { channels_with_admin_privileges: HashSet, outgoing_invites: HashSet<(ChannelId, UserId)>, update_channels_tx: mpsc::UnboundedSender, - opened_buffers: HashMap, + opened_buffers: HashMap>, + opened_chats: HashMap>, client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, @@ -50,15 +51,9 @@ impl Entity for ChannelStore { type Event = ChannelEvent; } -pub enum ChannelMemberStatus { - Invited, - Member, - NotMember, -} - -enum OpenedChannelBuffer { - Open(WeakModelHandle), - Loading(Shared, Arc>>>), +enum OpenedModelHandle { + Open(WeakModelHandle), + Loading(Shared, Arc>>>), } impl ChannelStore { @@ -94,6 +89,7 @@ impl ChannelStore { channels_with_admin_privileges: Default::default(), outgoing_invites: Default::default(), opened_buffers: Default::default(), + opened_chats: Default::default(), update_channels_tx, client, user_store, @@ -154,7 +150,7 @@ impl ChannelStore { pub fn has_open_channel_buffer(&self, channel_id: ChannelId, cx: &AppContext) -> bool { if let Some(buffer) = self.opened_buffers.get(&channel_id) { - if let OpenedChannelBuffer::Open(buffer) = buffer { + if let OpenedModelHandle::Open(buffer) = buffer { return buffer.upgrade(cx).is_some(); } } @@ -166,24 +162,58 @@ impl ChannelStore { channel_id: ChannelId, cx: &mut ModelContext, ) -> Task>> { - // Make sure that a given channel buffer is only opened once per + let client = self.client.clone(); + self.open_channel_resource( + channel_id, + |this| &mut this.opened_buffers, + |channel, cx| ChannelBuffer::new(channel, client, cx), + cx, + ) + } + + pub fn open_channel_chat( + &mut self, + channel_id: ChannelId, + cx: &mut ModelContext, + ) -> Task>> { + let client = self.client.clone(); + let user_store = self.user_store.clone(); + self.open_channel_resource( + channel_id, + |this| &mut this.opened_chats, + |channel, cx| ChannelChat::new(channel, user_store, client, cx), + cx, + ) + } + + fn open_channel_resource( + &mut self, + channel_id: ChannelId, + map: fn(&mut Self) -> &mut HashMap>, + load: F, + cx: &mut ModelContext, + ) -> Task>> + where + F: 'static + FnOnce(Arc, AsyncAppContext) -> Fut, + Fut: Future>>, + { + // Make sure that a given channel resource is only opened once per // app instance, even if this method is called multiple times // with the same channel id while the first task is still running. let task = loop { - match self.opened_buffers.entry(channel_id) { + match map(self).entry(channel_id) { hash_map::Entry::Occupied(e) => match e.get() { - OpenedChannelBuffer::Open(buffer) => { + OpenedModelHandle::Open(buffer) => { if let Some(buffer) = buffer.upgrade(cx) { break Task::ready(Ok(buffer)).shared(); } else { - self.opened_buffers.remove(&channel_id); + map(self).remove(&channel_id); continue; } } - OpenedChannelBuffer::Loading(task) => break task.clone(), + OpenedModelHandle::Loading(task) => break task.clone(), }, hash_map::Entry::Vacant(e) => { - let client = self.client.clone(); let task = cx .spawn(|this, cx| async move { let channel = this.read_with(&cx, |this, _| { @@ -192,12 +222,10 @@ impl ChannelStore { }) })?; - ChannelBuffer::new(channel, client, cx) - .await - .map_err(Arc::new) + load(channel, cx).await.map_err(Arc::new) }) .shared(); - e.insert(OpenedChannelBuffer::Loading(task.clone())); + e.insert(OpenedModelHandle::Loading(task.clone())); cx.spawn({ let task = task.clone(); |this, mut cx| async move { @@ -208,14 +236,14 @@ impl ChannelStore { this.opened_buffers.remove(&channel_id); }) .detach(); - this.opened_buffers.insert( + map(this).insert( channel_id, - OpenedChannelBuffer::Open(buffer.downgrade()), + OpenedModelHandle::Open(buffer.downgrade()), ); } Err(error) => { log::error!("failed to open channel buffer {error:?}"); - this.opened_buffers.remove(&channel_id); + map(this).remove(&channel_id); } }); } @@ -496,7 +524,7 @@ impl ChannelStore { let mut buffer_versions = Vec::new(); for buffer in self.opened_buffers.values() { - if let OpenedChannelBuffer::Open(buffer) = buffer { + if let OpenedModelHandle::Open(buffer) = buffer { if let Some(buffer) = buffer.upgrade(cx) { let channel_buffer = buffer.read(cx); let buffer = channel_buffer.buffer().read(cx); @@ -522,7 +550,7 @@ impl ChannelStore { this.update(&mut cx, |this, cx| { this.opened_buffers.retain(|_, buffer| match buffer { - OpenedChannelBuffer::Open(channel_buffer) => { + OpenedModelHandle::Open(channel_buffer) => { let Some(channel_buffer) = channel_buffer.upgrade(cx) else { return false; }; @@ -583,7 +611,7 @@ impl ChannelStore { false }) } - OpenedChannelBuffer::Loading(_) => true, + OpenedModelHandle::Loading(_) => true, }); }); anyhow::Ok(()) @@ -605,7 +633,7 @@ impl ChannelStore { if let Some(this) = this.upgrade(&cx) { this.update(&mut cx, |this, cx| { for (_, buffer) in this.opened_buffers.drain() { - if let OpenedChannelBuffer::Open(buffer) = buffer { + if let OpenedModelHandle::Open(buffer) = buffer { if let Some(buffer) = buffer.upgrade(cx) { buffer.update(cx, |buffer, cx| buffer.disconnect(cx)); } @@ -654,7 +682,7 @@ impl ChannelStore { for channel_id in &payload.remove_channels { let channel_id = *channel_id; - if let Some(OpenedChannelBuffer::Open(buffer)) = + if let Some(OpenedModelHandle::Open(buffer)) = self.opened_buffers.remove(&channel_id) { if let Some(buffer) = buffer.upgrade(cx) { diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index 18894b1f472f907d3b54ad35df57d78e5e974565..3ae899ecde8ed2f44628ca9b2c31f44d9caf8bcb 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -1,6 +1,8 @@ +use crate::channel_chat::ChannelChatEvent; + use super::*; -use client::{Client, UserStore}; -use gpui::{AppContext, ModelHandle}; +use client::{test::FakeServer, Client, UserStore}; +use gpui::{AppContext, ModelHandle, TestAppContext}; use rpc::proto; use util::http::FakeHttpClient; @@ -137,6 +139,220 @@ fn test_dangling_channel_paths(cx: &mut AppContext) { assert_channels(&channel_store, &[(0, "a".to_string(), true)], cx); } +#[gpui::test] +async fn test_channel_messages(cx: &mut TestAppContext) { + cx.foreground().forbid_parking(); + + let user_id = 5; + let http_client = FakeHttpClient::with_404_response(); + let client = cx.update(|cx| Client::new(http_client.clone(), cx)); + let server = FakeServer::for_client(user_id, &client, cx).await; + let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); + crate::init(&client); + + let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx)); + + let channel_id = 5; + + // Get the available channels. + server.send(proto::UpdateChannels { + channels: vec![proto::Channel { + id: channel_id, + name: "the-channel".to_string(), + parent_id: None, + }], + ..Default::default() + }); + channel_store.next_notification(cx).await; + cx.read(|cx| { + assert_channels(&channel_store, &[(0, "the-channel".to_string(), false)], cx); + }); + + let get_users = server.receive::().await.unwrap(); + assert_eq!(get_users.payload.user_ids, vec![5]); + server + .respond( + get_users.receipt(), + proto::UsersResponse { + users: vec![proto::User { + id: 5, + github_login: "nathansobo".into(), + avatar_url: "http://avatar.com/nathansobo".into(), + }], + }, + ) + .await; + + // Join a channel and populate its existing messages. + let channel = channel_store + .update(cx, |store, cx| { + let channel_id = store.channels().next().unwrap().1.id; + store.open_channel_chat(channel_id, cx) + }) + .await + .unwrap(); + channel.read_with(cx, |channel, _| assert!(channel.messages().is_empty())); + let join_channel = server.receive::().await.unwrap(); + server + .respond( + join_channel.receipt(), + proto::JoinChannelChatResponse { + messages: vec![ + proto::ChannelMessage { + id: 10, + body: "a".into(), + timestamp: 1000, + sender_id: 5, + nonce: Some(1.into()), + }, + proto::ChannelMessage { + id: 11, + body: "b".into(), + timestamp: 1001, + sender_id: 6, + nonce: Some(2.into()), + }, + ], + done: false, + }, + ) + .await; + + // Client requests all users for the received messages + let mut get_users = server.receive::().await.unwrap(); + get_users.payload.user_ids.sort(); + assert_eq!(get_users.payload.user_ids, vec![6]); + server + .respond( + get_users.receipt(), + proto::UsersResponse { + users: vec![proto::User { + id: 6, + github_login: "maxbrunsfeld".into(), + avatar_url: "http://avatar.com/maxbrunsfeld".into(), + }], + }, + ) + .await; + + assert_eq!( + channel.next_event(cx).await, + ChannelChatEvent::MessagesUpdated { + old_range: 0..0, + new_count: 2, + } + ); + channel.read_with(cx, |channel, _| { + assert_eq!( + channel + .messages_in_range(0..2) + .map(|message| (message.sender.github_login.clone(), message.body.clone())) + .collect::>(), + &[ + ("nathansobo".into(), "a".into()), + ("maxbrunsfeld".into(), "b".into()) + ] + ); + }); + + // Receive a new message. + server.send(proto::ChannelMessageSent { + channel_id, + message: Some(proto::ChannelMessage { + id: 12, + body: "c".into(), + timestamp: 1002, + sender_id: 7, + nonce: Some(3.into()), + }), + }); + + // Client requests user for message since they haven't seen them yet + let get_users = server.receive::().await.unwrap(); + assert_eq!(get_users.payload.user_ids, vec![7]); + server + .respond( + get_users.receipt(), + proto::UsersResponse { + users: vec![proto::User { + id: 7, + github_login: "as-cii".into(), + avatar_url: "http://avatar.com/as-cii".into(), + }], + }, + ) + .await; + + assert_eq!( + channel.next_event(cx).await, + ChannelChatEvent::MessagesUpdated { + old_range: 2..2, + new_count: 1, + } + ); + channel.read_with(cx, |channel, _| { + assert_eq!( + channel + .messages_in_range(2..3) + .map(|message| (message.sender.github_login.clone(), message.body.clone())) + .collect::>(), + &[("as-cii".into(), "c".into())] + ) + }); + + // Scroll up to view older messages. + channel.update(cx, |channel, cx| { + assert!(channel.load_more_messages(cx)); + }); + let get_messages = server.receive::().await.unwrap(); + assert_eq!(get_messages.payload.channel_id, 5); + assert_eq!(get_messages.payload.before_message_id, 10); + server + .respond( + get_messages.receipt(), + proto::GetChannelMessagesResponse { + done: true, + messages: vec![ + proto::ChannelMessage { + id: 8, + body: "y".into(), + timestamp: 998, + sender_id: 5, + nonce: Some(4.into()), + }, + proto::ChannelMessage { + id: 9, + body: "z".into(), + timestamp: 999, + sender_id: 6, + nonce: Some(5.into()), + }, + ], + }, + ) + .await; + + assert_eq!( + channel.next_event(cx).await, + ChannelChatEvent::MessagesUpdated { + old_range: 0..0, + new_count: 2, + } + ); + channel.read_with(cx, |channel, _| { + assert_eq!( + channel + .messages_in_range(0..2) + .map(|message| (message.sender.github_login.clone(), message.body.clone())) + .collect::>(), + &[ + ("nathansobo".into(), "y".into()), + ("maxbrunsfeld".into(), "z".into()) + ] + ); + }); +} + fn update_channels( channel_store: &ModelHandle, message: proto::UpdateChannels, diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index eef1dde96755701cf39a78094aeb19f737f95d3e..a7dbd97239079b41ff62a5e3e1a3cae4b9051986 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -6,7 +6,7 @@ use crate::{ }; use anyhow::anyhow; use call::ActiveCall; -use channel::{channel_buffer::ChannelBuffer, ChannelStore}; +use channel::{ChannelBuffer, ChannelStore}; use client::{ self, proto::PeerId, Client, Connection, Credentials, EstablishConnectionError, UserStore, }; diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index da32308558f7c7e8279c420961f8d42d9356d37b..0a52b9a19fe9df7c070e33de1d8c719b0dd8dae2 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -55,6 +55,7 @@ schemars.workspace = true postage.workspace = true serde.workspace = true serde_derive.workspace = true +time.workspace = true [dev-dependencies] call = { path = "../call", features = ["test-support"] } diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 5086cc8b37739aa74710d84c7f90e4040bc02f15..76b79eaf395a54a5b14fcd4c556d8271a1196168 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -1,8 +1,5 @@ use anyhow::{anyhow, Result}; -use channel::{ - channel_buffer::{self, ChannelBuffer}, - ChannelId, -}; +use channel::{ChannelBuffer, ChannelBufferEvent, ChannelId}; use client::proto; use clock::ReplicaId; use collections::HashMap; @@ -118,14 +115,14 @@ impl ChannelView { fn handle_channel_buffer_event( &mut self, _: ModelHandle, - event: &channel_buffer::Event, + event: &ChannelBufferEvent, cx: &mut ViewContext, ) { match event { - channel_buffer::Event::CollaboratorsChanged => { + ChannelBufferEvent::CollaboratorsChanged => { self.refresh_replica_id_map(cx); } - channel_buffer::Event::Disconnected => self.editor.update(cx, |editor, cx| { + ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| { editor.set_read_only(true); cx.notify(); }), diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..0105a9d27b2fa67d34b660b7cf18640169286bd7 --- /dev/null +++ b/crates/collab_ui/src/chat_panel.rs @@ -0,0 +1,391 @@ +use channel::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelStore}; +use client::Client; +use editor::Editor; +use gpui::{ + actions, + elements::*, + platform::{CursorStyle, MouseButton}, + views::{ItemType, Select, SelectStyle}, + AnyViewHandle, AppContext, Entity, ModelHandle, Subscription, View, ViewContext, ViewHandle, +}; +use language::language_settings::SoftWrap; +use menu::Confirm; +use std::sync::Arc; +use theme::Theme; +use time::{OffsetDateTime, UtcOffset}; +use util::{ResultExt, TryFutureExt}; + +const MESSAGE_LOADING_THRESHOLD: usize = 50; + +pub struct ChatPanel { + client: Arc, + channel_store: ModelHandle, + active_channel: Option<(ModelHandle, Subscription)>, + message_list: ListState, + input_editor: ViewHandle, + channel_select: ViewHandle { + let channel = &channel_store.read(cx).channel_at_index(ix).unwrap().1; + let theme = match (item_type, is_hovered) { + (ItemType::Header, _) => &theme.header, + (ItemType::Selected, false) => &theme.active_item, + (ItemType::Selected, true) => &theme.hovered_active_item, + (ItemType::Unselected, false) => &theme.item, + (ItemType::Unselected, true) => &theme.hovered_item, + }; + Flex::row() + .with_child( + Label::new("#".to_string(), theme.hash.text.clone()) + .contained() + .with_style(theme.hash.container), + ) + .with_child(Label::new(channel.name.clone(), theme.name.clone())) + .contained() + .with_style(theme.container) + .into_any() + } + + fn render_sign_in_prompt( + &self, + theme: &Arc, + cx: &mut ViewContext, + ) -> AnyElement { + enum SignInPromptLabel {} + + MouseEventHandler::new::(0, cx, |mouse_state, _| { + Label::new( + "Sign in to use chat".to_string(), + theme + .chat_panel + .sign_in_prompt + .style_for(mouse_state) + .clone(), + ) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + let client = this.client.clone(); + cx.spawn(|this, mut cx| async move { + if client + .authenticate_and_connect(true, &cx) + .log_err() + .await + .is_some() + { + this.update(&mut cx, |this, cx| { + if cx.handle().is_focused(cx) { + cx.focus(&this.input_editor); + } + }) + .ok(); + } + }) + .detach(); + }) + .aligned() + .into_any() + } + + fn send(&mut self, _: &Confirm, cx: &mut ViewContext) { + if let Some((channel, _)) = self.active_channel.as_ref() { + let body = self.input_editor.update(cx, |editor, cx| { + let body = editor.text(cx); + editor.clear(cx); + body + }); + + if let Some(task) = channel + .update(cx, |channel, cx| channel.send_message(body, cx)) + .log_err() + { + task.detach(); + } + } + } + + fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext) { + if let Some((channel, _)) = self.active_channel.as_ref() { + channel.update(cx, |channel, cx| { + channel.load_more_messages(cx); + }) + } + } +} + +impl Entity for ChatPanel { + type Event = Event; +} + +impl View for ChatPanel { + fn ui_name() -> &'static str { + "ChatPanel" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let theme = theme::current(cx); + let element = if self.client.user_id().is_some() { + self.render_channel(cx) + } else { + self.render_sign_in_prompt(&theme, cx) + }; + element + .contained() + .with_style(theme.chat_panel.container) + .constrained() + .with_min_width(150.) + .into_any() + } + + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + if matches!( + *self.client.status().borrow(), + client::Status::Connected { .. } + ) { + cx.focus(&self.input_editor); + } + } +} + +fn format_timestamp( + mut timestamp: OffsetDateTime, + mut now: OffsetDateTime, + local_timezone: UtcOffset, +) -> String { + timestamp = timestamp.to_offset(local_timezone); + now = now.to_offset(local_timezone); + + let today = now.date(); + let date = timestamp.date(); + let mut hour = timestamp.hour(); + let mut part = "am"; + if hour > 12 { + hour -= 12; + part = "pm"; + } + if date == today { + format!("{:02}:{:02}{}", hour, timestamp.minute(), part) + } else if date.next_day() == Some(today) { + format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part) + } else { + format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) + } +} diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index ee34f600fa9b838cc00d0ae1ba084ef13f1c1f4c..b40ecc6992c6c6e643faa100c88a42f1ac32ff1a 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -1,4 +1,5 @@ pub mod channel_view; +pub mod chat_panel; pub mod collab_panel; mod collab_titlebar_item; mod contact_notification; diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 2e96d79f5e50b21a0e1a0444b1f62cb3ad23f32e..eb26e81b9980512e0f69c6eb663390cb0da46c2f 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -155,7 +155,16 @@ message Envelope { RemoveChannelBufferCollaborator remove_channel_buffer_collaborator = 136; UpdateChannelBufferCollaborator update_channel_buffer_collaborator = 139; RejoinChannelBuffers rejoin_channel_buffers = 140; - RejoinChannelBuffersResponse rejoin_channel_buffers_response = 141; // Current max + RejoinChannelBuffersResponse rejoin_channel_buffers_response = 141; + + JoinChannelChat join_channel_chat = 142; + JoinChannelChatResponse join_channel_chat_response = 143; + LeaveChannelChat leave_channel_chat = 144; + SendChannelMessage send_channel_message = 145; + SendChannelMessageResponse send_channel_message_response = 146; + ChannelMessageSent channel_message_sent = 147; + GetChannelMessages get_channel_messages = 148; + GetChannelMessagesResponse get_channel_messages_response = 149; // Current max } } @@ -1021,10 +1030,56 @@ message RenameChannel { string name = 2; } +message JoinChannelChat { + uint64 channel_id = 1; +} + +message JoinChannelChatResponse { + repeated ChannelMessage messages = 1; + bool done = 2; +} + +message LeaveChannelChat { + uint64 channel_id = 1; +} + +message SendChannelMessage { + uint64 channel_id = 1; + string body = 2; + Nonce nonce = 3; +} + +message SendChannelMessageResponse { + ChannelMessage message = 1; +} + +message ChannelMessageSent { + uint64 channel_id = 1; + ChannelMessage message = 2; +} + +message GetChannelMessages { + uint64 channel_id = 1; + uint64 before_message_id = 2; +} + +message GetChannelMessagesResponse { + repeated ChannelMessage messages = 1; + bool done = 2; +} + message JoinChannelBuffer { uint64 channel_id = 1; } +message ChannelMessage { + uint64 id = 1; + string body = 2; + uint64 timestamp = 3; + uint64 sender_id = 4; + Nonce nonce = 5; +} + message RejoinChannelBuffers { repeated ChannelBufferVersion buffers = 1; } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index f643a8c168dc4088db65d48e7678c19363b434ff..7b98f50e75c4d47bf84ef751c143a05b4508198c 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -147,6 +147,7 @@ messages!( (CreateBufferForPeer, Foreground), (CreateChannel, Foreground), (ChannelResponse, Foreground), + (ChannelMessageSent, Foreground), (CreateProjectEntry, Foreground), (CreateRoom, Foreground), (CreateRoomResponse, Foreground), @@ -163,6 +164,10 @@ messages!( (GetCodeActionsResponse, Background), (GetHover, Background), (GetHoverResponse, Background), + (GetChannelMessages, Background), + (GetChannelMessagesResponse, Background), + (SendChannelMessage, Background), + (SendChannelMessageResponse, Background), (GetCompletions, Background), (GetCompletionsResponse, Background), (GetDefinition, Background), @@ -184,6 +189,9 @@ messages!( (JoinProjectResponse, Foreground), (JoinRoom, Foreground), (JoinRoomResponse, Foreground), + (JoinChannelChat, Foreground), + (JoinChannelChatResponse, Foreground), + (LeaveChannelChat, Foreground), (LeaveProject, Foreground), (LeaveRoom, Foreground), (OpenBufferById, Background), @@ -293,6 +301,7 @@ request_messages!( (InviteChannelMember, Ack), (JoinProject, JoinProjectResponse), (JoinRoom, JoinRoomResponse), + (JoinChannelChat, JoinChannelChatResponse), (LeaveRoom, Ack), (RejoinRoom, RejoinRoomResponse), (IncomingCall, Ack), @@ -313,6 +322,8 @@ request_messages!( (RespondToContactRequest, Ack), (RespondToChannelInvite, Ack), (SetChannelMemberAdmin, Ack), + (SendChannelMessage, SendChannelMessageResponse), + (GetChannelMessages, GetChannelMessagesResponse), (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), (RemoveChannel, Ack), @@ -388,6 +399,7 @@ entity_messages!( entity_messages!( channel_id, + ChannelMessageSent, UpdateChannelBuffer, RemoveChannelBufferCollaborator, AddChannelBufferCollaborator, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index a54224978841ba6568e2008c5aacb6a36010f9aa..15d7e1a71fb2974db852f8625b115c6b436d958d 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -49,6 +49,7 @@ pub struct Theme { pub copilot: Copilot, pub collab_panel: CollabPanel, pub project_panel: ProjectPanel, + pub chat_panel: ChatPanel, pub command_palette: CommandPalette, pub picker: Picker, pub editor: Editor, @@ -611,6 +612,17 @@ pub struct IconButton { pub button_width: f32, } +#[derive(Deserialize, Default, JsonSchema)] +pub struct ChatPanel { + #[serde(flatten)] + pub container: ContainerStyle, + pub channel_select: ChannelSelect, + pub input_editor: FieldEditor, + pub message: ChatMessage, + pub pending_message: ChatMessage, + pub sign_in_prompt: Interactive, +} + #[derive(Deserialize, Default, JsonSchema)] pub struct ChatMessage { #[serde(flatten)] From da5a77badf9f39ac7e2614b2c33fd65fc1857629 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 7 Sep 2023 12:24:25 -0700 Subject: [PATCH 02/19] Start work on restoring server-side code for chat messages --- crates/channel/src/channel.rs | 1 + crates/channel/src/channel_chat.rs | 8 +- .../20221109000000_test_schema.sql | 20 +++ .../20230907114200_add_channel_messages.sql | 19 +++ crates/collab/src/db/ids.rs | 2 + crates/collab/src/db/queries.rs | 1 + crates/collab/src/db/queries/messages.rs | 152 ++++++++++++++++++ crates/collab/src/db/tables.rs | 2 + crates/collab/src/db/tables/channel.rs | 8 + .../src/db/tables/channel_chat_participant.rs | 41 +++++ .../collab/src/db/tables/channel_message.rs | 45 ++++++ crates/collab/src/db/tests.rs | 1 + crates/collab/src/db/tests/message_tests.rs | 53 ++++++ crates/collab/src/rpc.rs | 119 +++++++++++++- crates/collab/src/tests.rs | 1 + .../collab/src/tests/channel_message_tests.rs | 56 +++++++ 16 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 crates/collab/migrations/20230907114200_add_channel_messages.sql create mode 100644 crates/collab/src/db/queries/messages.rs create mode 100644 crates/collab/src/db/tables/channel_chat_participant.rs create mode 100644 crates/collab/src/db/tables/channel_message.rs create mode 100644 crates/collab/src/db/tests/message_tests.rs create mode 100644 crates/collab/src/tests/channel_message_tests.rs diff --git a/crates/channel/src/channel.rs b/crates/channel/src/channel.rs index b4acc0c25fcb8587dc7b507ced5e78156fd94c45..37f1c0ce44ba8a8f3a86247ea411ba0d2b669f7d 100644 --- a/crates/channel/src/channel.rs +++ b/crates/channel/src/channel.rs @@ -14,4 +14,5 @@ mod channel_store_tests; pub fn init(client: &Arc) { channel_buffer::init(client); + channel_chat::init(client); } diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index ebe491e5b896ac1b638237b0ad0df113a2e923b3..3916189363a040aba0b84d619c9bedbec8d72df8 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -57,6 +57,10 @@ pub enum ChannelChatEvent { }, } +pub fn init(client: &Arc) { + client.add_model_message_handler(ChannelChat::handle_message_sent); +} + impl Entity for ChannelChat { type Event = ChannelChatEvent; @@ -70,10 +74,6 @@ impl Entity for ChannelChat { } impl ChannelChat { - pub fn init(rpc: &Arc) { - rpc.add_model_message_handler(Self::handle_message_sent); - } - pub async fn new( channel: Arc, user_store: ModelHandle, diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 80477dcb3c3b9f4fc1efd25622243b59901cf4fc..ab12039b10bef0f02b117720d2d53e9d99080792 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -192,6 +192,26 @@ CREATE TABLE "channels" ( "created_at" TIMESTAMP NOT NULL DEFAULT now ); +CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL REFERENCES users (id), + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "connection_id" INTEGER NOT NULL, + "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE +); +CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id"); + +CREATE TABLE IF NOT EXISTS "channel_messages" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "sender_id" INTEGER NOT NULL REFERENCES users (id), + "body" TEXT NOT NULL, + "sent_at" TIMESTAMP, + "nonce" BLOB NOT NULL +); +CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id"); +CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce"); + CREATE TABLE "channel_paths" ( "id_path" TEXT NOT NULL PRIMARY KEY, "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE diff --git a/crates/collab/migrations/20230907114200_add_channel_messages.sql b/crates/collab/migrations/20230907114200_add_channel_messages.sql new file mode 100644 index 0000000000000000000000000000000000000000..abe7753ca69fb45a1f0a56b732963d8dc5605e31 --- /dev/null +++ b/crates/collab/migrations/20230907114200_add_channel_messages.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS "channel_messages" ( + "id" SERIAL PRIMARY KEY, + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "sender_id" INTEGER NOT NULL REFERENCES users (id), + "body" TEXT NOT NULL, + "sent_at" TIMESTAMP, + "nonce" UUID NOT NULL +); +CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id"); +CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce"); + +CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( + "id" SERIAL PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES users (id), + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "connection_id" INTEGER NOT NULL, + "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE +); +CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id"); diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index b33ea57183b8771792ea50c6b3ab2b2631971194..865a39fd7154f6aa9ac993a5c7a4b53f8a302c6a 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -112,8 +112,10 @@ fn value_to_integer(v: Value) -> Result { id_type!(BufferId); id_type!(AccessTokenId); +id_type!(ChannelChatParticipantId); id_type!(ChannelId); id_type!(ChannelMemberId); +id_type!(MessageId); id_type!(ContactId); id_type!(FollowerId); id_type!(RoomId); diff --git a/crates/collab/src/db/queries.rs b/crates/collab/src/db/queries.rs index 09a8f073b469f72773a0220750f5d65cf85629af..54db0663d2e4d631b657cc44a81b2dc1382a49bc 100644 --- a/crates/collab/src/db/queries.rs +++ b/crates/collab/src/db/queries.rs @@ -4,6 +4,7 @@ pub mod access_tokens; pub mod buffers; pub mod channels; pub mod contacts; +pub mod messages; pub mod projects; pub mod rooms; pub mod servers; diff --git a/crates/collab/src/db/queries/messages.rs b/crates/collab/src/db/queries/messages.rs new file mode 100644 index 0000000000000000000000000000000000000000..e8b9ec74165efa388b2af7c3a27d813278a0f461 --- /dev/null +++ b/crates/collab/src/db/queries/messages.rs @@ -0,0 +1,152 @@ +use super::*; +use time::OffsetDateTime; + +impl Database { + pub async fn join_channel_chat( + &self, + channel_id: ChannelId, + connection_id: ConnectionId, + user_id: UserId, + ) -> Result<()> { + self.transaction(|tx| async move { + self.check_user_is_channel_member(channel_id, user_id, &*tx) + .await?; + channel_chat_participant::ActiveModel { + id: ActiveValue::NotSet, + channel_id: ActiveValue::Set(channel_id), + user_id: ActiveValue::Set(user_id), + connection_id: ActiveValue::Set(connection_id.id as i32), + connection_server_id: ActiveValue::Set(ServerId(connection_id.owner_id as i32)), + } + .insert(&*tx) + .await?; + Ok(()) + }) + .await + } + + pub async fn leave_channel_chat( + &self, + channel_id: ChannelId, + connection_id: ConnectionId, + _user_id: UserId, + ) -> Result<()> { + self.transaction(|tx| async move { + channel_chat_participant::Entity::delete_many() + .filter( + Condition::all() + .add( + channel_chat_participant::Column::ConnectionServerId + .eq(connection_id.owner_id), + ) + .add(channel_chat_participant::Column::ConnectionId.eq(connection_id.id)) + .add(channel_chat_participant::Column::ChannelId.eq(channel_id)), + ) + .exec(&*tx) + .await?; + + Ok(()) + }) + .await + } + + pub async fn get_channel_messages( + &self, + channel_id: ChannelId, + user_id: UserId, + count: usize, + before_message_id: Option, + ) -> Result> { + self.transaction(|tx| async move { + self.check_user_is_channel_member(channel_id, user_id, &*tx) + .await?; + + let mut condition = + Condition::all().add(channel_message::Column::ChannelId.eq(channel_id)); + + if let Some(before_message_id) = before_message_id { + condition = condition.add(channel_message::Column::Id.lt(before_message_id)); + } + + let mut rows = channel_message::Entity::find() + .filter(condition) + .limit(count as u64) + .stream(&*tx) + .await?; + + let mut messages = Vec::new(); + while let Some(row) = rows.next().await { + let row = row?; + let nonce = row.nonce.as_u64_pair(); + messages.push(proto::ChannelMessage { + id: row.id.to_proto(), + sender_id: row.sender_id.to_proto(), + body: row.body, + timestamp: row.sent_at.unix_timestamp() as u64, + nonce: Some(proto::Nonce { + upper_half: nonce.0, + lower_half: nonce.1, + }), + }); + } + + Ok(messages) + }) + .await + } + + pub async fn create_channel_message( + &self, + channel_id: ChannelId, + user_id: UserId, + body: &str, + timestamp: OffsetDateTime, + nonce: u128, + ) -> Result<(MessageId, Vec)> { + self.transaction(|tx| async move { + let mut rows = channel_chat_participant::Entity::find() + .filter(channel_chat_participant::Column::ChannelId.eq(channel_id)) + .stream(&*tx) + .await?; + + let mut is_participant = false; + let mut participant_connection_ids = Vec::new(); + while let Some(row) = rows.next().await { + let row = row?; + if row.user_id == user_id { + is_participant = true; + } + participant_connection_ids.push(row.connection()); + } + drop(rows); + + if !is_participant { + Err(anyhow!("not a chat participant"))?; + } + + let message = channel_message::Entity::insert(channel_message::ActiveModel { + channel_id: ActiveValue::Set(channel_id), + sender_id: ActiveValue::Set(user_id), + body: ActiveValue::Set(body.to_string()), + sent_at: ActiveValue::Set(timestamp), + nonce: ActiveValue::Set(Uuid::from_u128(nonce)), + id: ActiveValue::NotSet, + }) + .on_conflict( + OnConflict::column(channel_message::Column::Nonce) + .update_column(channel_message::Column::Nonce) + .to_owned(), + ) + .exec(&*tx) + .await?; + + #[derive(Debug, Clone, Copy, EnumIter, DeriveColumn)] + enum QueryConnectionId { + ConnectionId, + } + + Ok((message.last_insert_id, participant_connection_ids)) + }) + .await + } +} diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index 1765cee065fb6c7ae31818568a229e3c3c0bd3f0..81200df3e7b51a72a5ca33a100ff08ecbc80b75c 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -4,7 +4,9 @@ pub mod buffer_operation; pub mod buffer_snapshot; pub mod channel; pub mod channel_buffer_collaborator; +pub mod channel_chat_participant; pub mod channel_member; +pub mod channel_message; pub mod channel_path; pub mod contact; pub mod feature_flag; diff --git a/crates/collab/src/db/tables/channel.rs b/crates/collab/src/db/tables/channel.rs index 05895ede4cf6b5080889cf281a1ce3651aebd1c2..54f12defc1b56570a0629e2e92a896ad167aa6d6 100644 --- a/crates/collab/src/db/tables/channel.rs +++ b/crates/collab/src/db/tables/channel.rs @@ -21,6 +21,8 @@ pub enum Relation { Member, #[sea_orm(has_many = "super::channel_buffer_collaborator::Entity")] BufferCollaborators, + #[sea_orm(has_many = "super::channel_chat_participant::Entity")] + ChatParticipants, } impl Related for Entity { @@ -46,3 +48,9 @@ impl Related for Entity { Relation::BufferCollaborators.def() } } + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChatParticipants.def() + } +} diff --git a/crates/collab/src/db/tables/channel_chat_participant.rs b/crates/collab/src/db/tables/channel_chat_participant.rs new file mode 100644 index 0000000000000000000000000000000000000000..f3ef36c289f86e5f20411cf9b3f442698f6a4024 --- /dev/null +++ b/crates/collab/src/db/tables/channel_chat_participant.rs @@ -0,0 +1,41 @@ +use crate::db::{ChannelChatParticipantId, ChannelId, ServerId, UserId}; +use rpc::ConnectionId; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "channel_chat_participants")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: ChannelChatParticipantId, + pub channel_id: ChannelId, + pub user_id: UserId, + pub connection_id: i32, + pub connection_server_id: ServerId, +} + +impl Model { + pub fn connection(&self) -> ConnectionId { + ConnectionId { + owner_id: self.connection_server_id.0 as u32, + id: self.connection_id as u32, + } + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::channel::Entity", + from = "Column::ChannelId", + to = "super::channel::Column::Id" + )] + Channel, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Channel.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tables/channel_message.rs b/crates/collab/src/db/tables/channel_message.rs new file mode 100644 index 0000000000000000000000000000000000000000..d043d5b6686fdd1b56581217cf63b6187fce4738 --- /dev/null +++ b/crates/collab/src/db/tables/channel_message.rs @@ -0,0 +1,45 @@ +use crate::db::{ChannelId, MessageId, UserId}; +use sea_orm::entity::prelude::*; +use time::OffsetDateTime; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "channel_messages")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: MessageId, + pub channel_id: ChannelId, + pub sender_id: UserId, + pub body: String, + pub sent_at: OffsetDateTime, + pub nonce: Uuid, +} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::channel::Entity", + from = "Column::ChannelId", + to = "super::channel::Column::Id" + )] + Channel, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::SenderId", + to = "super::user::Column::Id" + )] + Sender, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Channel.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Sender.def() + } +} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index ee961006cbbf74b019141c0973aca18d73309012..1d6c550865098a9afb2c5ca63e7357c64e46eaf8 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -1,6 +1,7 @@ mod buffer_tests; mod db_tests; mod feature_flag_tests; +mod message_tests; use super::*; use gpui::executor::Background; diff --git a/crates/collab/src/db/tests/message_tests.rs b/crates/collab/src/db/tests/message_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1dd686a1d28534b8e71a6d2b700a8cdd0c467777 --- /dev/null +++ b/crates/collab/src/db/tests/message_tests.rs @@ -0,0 +1,53 @@ +use crate::{ + db::{Database, NewUserParams}, + test_both_dbs, +}; +use std::sync::Arc; +use time::OffsetDateTime; + +test_both_dbs!( + test_channel_message_nonces, + test_channel_message_nonces_postgres, + test_channel_message_nonces_sqlite +); + +async fn test_channel_message_nonces(db: &Arc) { + let user = db + .create_user( + "user@example.com", + false, + NewUserParams { + github_login: "user".into(), + github_user_id: 1, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let channel = db + .create_channel("channel", None, "room", user) + .await + .unwrap(); + + let msg1_id = db + .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1) + .await + .unwrap(); + let msg2_id = db + .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2) + .await + .unwrap(); + let msg3_id = db + .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1) + .await + .unwrap(); + let msg4_id = db + .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2) + .await + .unwrap(); + + assert_ne!(msg1_id, msg2_id); + assert_eq!(msg1_id, msg3_id); + assert_eq!(msg2_id, msg4_id); +} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index e454fcbb9e7a4f2202602ca1ef7947ea6d6b6c9b..b508c45dc53f0a1017d44d1df96643165fc59b53 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2,7 +2,10 @@ mod connection_pool; use crate::{ auth, - db::{self, ChannelId, ChannelsForUser, Database, ProjectId, RoomId, ServerId, User, UserId}, + db::{ + self, ChannelId, ChannelsForUser, Database, MessageId, ProjectId, RoomId, ServerId, User, + UserId, + }, executor::Executor, AppState, Result, }; @@ -56,6 +59,7 @@ use std::{ }, time::{Duration, Instant}, }; +use time::OffsetDateTime; use tokio::sync::{watch, Semaphore}; use tower::ServiceBuilder; use tracing::{info_span, instrument, Instrument}; @@ -63,6 +67,9 @@ use tracing::{info_span, instrument, Instrument}; pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); +const MESSAGE_COUNT_PER_PAGE: usize = 100; +const MAX_MESSAGE_LEN: usize = 1024; + lazy_static! { static ref METRIC_CONNECTIONS: IntGauge = register_int_gauge!("connections", "number of connections").unwrap(); @@ -255,6 +262,10 @@ impl Server { .add_request_handler(get_channel_members) .add_request_handler(respond_to_channel_invite) .add_request_handler(join_channel) + .add_request_handler(join_channel_chat) + .add_message_handler(leave_channel_chat) + .add_request_handler(send_channel_message) + .add_request_handler(get_channel_messages) .add_request_handler(follow) .add_message_handler(unfollow) .add_message_handler(update_followers) @@ -2641,6 +2652,112 @@ fn channel_buffer_updated( }); } +async fn send_channel_message( + request: proto::SendChannelMessage, + response: Response, + session: Session, +) -> Result<()> { + // Validate the message body. + let body = request.body.trim().to_string(); + if body.len() > MAX_MESSAGE_LEN { + return Err(anyhow!("message is too long"))?; + } + if body.is_empty() { + return Err(anyhow!("message can't be blank"))?; + } + + let timestamp = OffsetDateTime::now_utc(); + let nonce = request + .nonce + .ok_or_else(|| anyhow!("nonce can't be blank"))?; + + let channel_id = ChannelId::from_proto(request.channel_id); + let (message_id, connection_ids) = session + .db() + .await + .create_channel_message( + channel_id, + session.user_id, + &body, + timestamp, + nonce.clone().into(), + ) + .await?; + let message = proto::ChannelMessage { + sender_id: session.user_id.to_proto(), + id: message_id.to_proto(), + body, + timestamp: timestamp.unix_timestamp() as u64, + nonce: Some(nonce), + }; + broadcast(Some(session.connection_id), connection_ids, |connection| { + session.peer.send( + connection, + proto::ChannelMessageSent { + channel_id: channel_id.to_proto(), + message: Some(message.clone()), + }, + ) + }); + response.send(proto::SendChannelMessageResponse { + message: Some(message), + })?; + Ok(()) +} + +async fn join_channel_chat( + request: proto::JoinChannelChat, + response: Response, + session: Session, +) -> Result<()> { + let channel_id = ChannelId::from_proto(request.channel_id); + + let db = session.db().await; + db.join_channel_chat(channel_id, session.connection_id, session.user_id) + .await?; + let messages = db + .get_channel_messages(channel_id, session.user_id, MESSAGE_COUNT_PER_PAGE, None) + .await?; + response.send(proto::JoinChannelChatResponse { + done: messages.len() < MESSAGE_COUNT_PER_PAGE, + messages, + })?; + Ok(()) +} + +async fn leave_channel_chat(request: proto::LeaveChannelChat, session: Session) -> Result<()> { + let channel_id = ChannelId::from_proto(request.channel_id); + session + .db() + .await + .leave_channel_chat(channel_id, session.connection_id, session.user_id) + .await?; + Ok(()) +} + +async fn get_channel_messages( + request: proto::GetChannelMessages, + response: Response, + session: Session, +) -> Result<()> { + let channel_id = ChannelId::from_proto(request.channel_id); + let messages = session + .db() + .await + .get_channel_messages( + channel_id, + session.user_id, + MESSAGE_COUNT_PER_PAGE, + Some(MessageId::from_proto(request.before_message_id)), + ) + .await?; + response.send(proto::GetChannelMessagesResponse { + done: messages.len() < MESSAGE_COUNT_PER_PAGE, + messages, + })?; + Ok(()) +} + async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> { let project_id = ProjectId::from_proto(request.project_id); let project_connection_ids = session diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 3000f0d8c351d2c5e3cc77ca89bd2ba344191ed8..b0f5b96fde04dcd9249550ea5975299793e7a894 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -2,6 +2,7 @@ use call::Room; use gpui::{ModelHandle, TestAppContext}; mod channel_buffer_tests; +mod channel_message_tests; mod channel_tests; mod integration_tests; mod random_channel_buffer_tests; diff --git a/crates/collab/src/tests/channel_message_tests.rs b/crates/collab/src/tests/channel_message_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e7afd136f36cfdf5d3be4eb8c9a79b49c0f6fa89 --- /dev/null +++ b/crates/collab/src/tests/channel_message_tests.rs @@ -0,0 +1,56 @@ +use crate::tests::TestServer; +use gpui::{executor::Deterministic, TestAppContext}; +use std::sync::Arc; + +#[gpui::test] +async fn test_basic_channel_messages( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel("the-channel", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + let channel_chat_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + let channel_chat_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + + channel_chat_a + .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap()) + .await + .unwrap(); + channel_chat_a + .update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap()) + .await + .unwrap(); + + deterministic.run_until_parked(); + channel_chat_b + .update(cx_b, |c, cx| c.send_message("three".into(), cx).unwrap()) + .await + .unwrap(); + + deterministic.run_until_parked(); + channel_chat_a.update(cx_a, |c, _| { + assert_eq!( + c.messages() + .iter() + .map(|m| m.body.as_str()) + .collect::>(), + vec!["one", "two", "three"] + ); + }) +} From ddda5a559b79ff11b9afa29e20a9a3f32799ec85 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 7 Sep 2023 18:06:05 -0700 Subject: [PATCH 03/19] Restore chat functionality with a very rough UI --- crates/channel/Cargo.toml | 1 + crates/channel/src/channel_store.rs | 4 + crates/channel/src/channel_store_tests.rs | 221 ++++++++--------- crates/client/src/test.rs | 9 +- crates/collab/src/db/queries/messages.rs | 5 +- .../collab/src/db/tables/channel_message.rs | 4 +- crates/collab/src/db/tests/message_tests.rs | 6 + crates/collab_ui/src/chat_panel.rs | 229 ++++++++++++++---- crates/collab_ui/src/collab_panel.rs | 15 +- crates/collab_ui/src/collab_ui.rs | 3 +- crates/zed/src/zed.rs | 15 +- styles/src/style_tree/app.ts | 2 + styles/src/style_tree/chat_panel.ts | 111 +++++++++ 13 files changed, 440 insertions(+), 185 deletions(-) create mode 100644 styles/src/style_tree/chat_panel.ts diff --git a/crates/channel/Cargo.toml b/crates/channel/Cargo.toml index c2191fdfa3edaaf0824e5e59ed974a7c53030ccd..00e9135bc1791f7a59e9270f48e9c9282f7b5b5d 100644 --- a/crates/channel/Cargo.toml +++ b/crates/channel/Cargo.toml @@ -47,5 +47,6 @@ tempfile = "3" collections = { path = "../collections", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] } +client = { path = "../client", features = ["test-support"] } settings = { path = "../settings", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 6096692ccba946eb77201750b0cb3a024e1f3b7f..48228041ecceaa18fe23aec902bd9566d877d158 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -111,6 +111,10 @@ impl ChannelStore { } } + pub fn client(&self) -> Arc { + self.client.clone() + } + pub fn has_children(&self, channel_id: ChannelId) -> bool { self.channel_paths.iter().any(|path| { if let Some(ix) = path.iter().position(|id| *id == channel_id) { diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index 3ae899ecde8ed2f44628ca9b2c31f44d9caf8bcb..22174f161b4139033b15d004b42ad3388b02fb26 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -4,15 +4,12 @@ use super::*; use client::{test::FakeServer, Client, UserStore}; use gpui::{AppContext, ModelHandle, TestAppContext}; use rpc::proto; +use settings::SettingsStore; use util::http::FakeHttpClient; #[gpui::test] fn test_update_channels(cx: &mut AppContext) { - let http = FakeHttpClient::with_404_response(); - let client = Client::new(http.clone(), cx); - let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); - - let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx)); + let channel_store = init_test(cx); update_channels( &channel_store, @@ -80,11 +77,7 @@ fn test_update_channels(cx: &mut AppContext) { #[gpui::test] fn test_dangling_channel_paths(cx: &mut AppContext) { - let http = FakeHttpClient::with_404_response(); - let client = Client::new(http.clone(), cx); - let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); - - let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx)); + let channel_store = init_test(cx); update_channels( &channel_store, @@ -141,18 +134,11 @@ fn test_dangling_channel_paths(cx: &mut AppContext) { #[gpui::test] async fn test_channel_messages(cx: &mut TestAppContext) { - cx.foreground().forbid_parking(); - let user_id = 5; - let http_client = FakeHttpClient::with_404_response(); - let client = cx.update(|cx| Client::new(http_client.clone(), cx)); - let server = FakeServer::for_client(user_id, &client, cx).await; - let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); - crate::init(&client); - - let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx)); - let channel_id = 5; + let channel_store = cx.update(init_test); + let client = channel_store.read_with(cx, |s, _| s.client()); + let server = FakeServer::for_client(user_id, &client, cx).await; // Get the available channels. server.send(proto::UpdateChannels { @@ -163,85 +149,71 @@ async fn test_channel_messages(cx: &mut TestAppContext) { }], ..Default::default() }); - channel_store.next_notification(cx).await; + cx.foreground().run_until_parked(); cx.read(|cx| { assert_channels(&channel_store, &[(0, "the-channel".to_string(), false)], cx); }); let get_users = server.receive::().await.unwrap(); assert_eq!(get_users.payload.user_ids, vec![5]); - server - .respond( - get_users.receipt(), - proto::UsersResponse { - users: vec![proto::User { - id: 5, - github_login: "nathansobo".into(), - avatar_url: "http://avatar.com/nathansobo".into(), - }], - }, - ) - .await; + server.respond( + get_users.receipt(), + proto::UsersResponse { + users: vec![proto::User { + id: 5, + github_login: "nathansobo".into(), + avatar_url: "http://avatar.com/nathansobo".into(), + }], + }, + ); // Join a channel and populate its existing messages. - let channel = channel_store - .update(cx, |store, cx| { - let channel_id = store.channels().next().unwrap().1.id; - store.open_channel_chat(channel_id, cx) - }) - .await - .unwrap(); - channel.read_with(cx, |channel, _| assert!(channel.messages().is_empty())); + let channel = channel_store.update(cx, |store, cx| { + let channel_id = store.channels().next().unwrap().1.id; + store.open_channel_chat(channel_id, cx) + }); let join_channel = server.receive::().await.unwrap(); - server - .respond( - join_channel.receipt(), - proto::JoinChannelChatResponse { - messages: vec![ - proto::ChannelMessage { - id: 10, - body: "a".into(), - timestamp: 1000, - sender_id: 5, - nonce: Some(1.into()), - }, - proto::ChannelMessage { - id: 11, - body: "b".into(), - timestamp: 1001, - sender_id: 6, - nonce: Some(2.into()), - }, - ], - done: false, - }, - ) - .await; + server.respond( + join_channel.receipt(), + proto::JoinChannelChatResponse { + messages: vec![ + proto::ChannelMessage { + id: 10, + body: "a".into(), + timestamp: 1000, + sender_id: 5, + nonce: Some(1.into()), + }, + proto::ChannelMessage { + id: 11, + body: "b".into(), + timestamp: 1001, + sender_id: 6, + nonce: Some(2.into()), + }, + ], + done: false, + }, + ); + + cx.foreground().start_waiting(); // Client requests all users for the received messages let mut get_users = server.receive::().await.unwrap(); get_users.payload.user_ids.sort(); assert_eq!(get_users.payload.user_ids, vec![6]); - server - .respond( - get_users.receipt(), - proto::UsersResponse { - users: vec![proto::User { - id: 6, - github_login: "maxbrunsfeld".into(), - avatar_url: "http://avatar.com/maxbrunsfeld".into(), - }], - }, - ) - .await; - - assert_eq!( - channel.next_event(cx).await, - ChannelChatEvent::MessagesUpdated { - old_range: 0..0, - new_count: 2, - } + server.respond( + get_users.receipt(), + proto::UsersResponse { + users: vec![proto::User { + id: 6, + github_login: "maxbrunsfeld".into(), + avatar_url: "http://avatar.com/maxbrunsfeld".into(), + }], + }, ); + + let channel = channel.await.unwrap(); channel.read_with(cx, |channel, _| { assert_eq!( channel @@ -270,18 +242,16 @@ async fn test_channel_messages(cx: &mut TestAppContext) { // Client requests user for message since they haven't seen them yet let get_users = server.receive::().await.unwrap(); assert_eq!(get_users.payload.user_ids, vec![7]); - server - .respond( - get_users.receipt(), - proto::UsersResponse { - users: vec![proto::User { - id: 7, - github_login: "as-cii".into(), - avatar_url: "http://avatar.com/as-cii".into(), - }], - }, - ) - .await; + server.respond( + get_users.receipt(), + proto::UsersResponse { + users: vec![proto::User { + id: 7, + github_login: "as-cii".into(), + avatar_url: "http://avatar.com/as-cii".into(), + }], + }, + ); assert_eq!( channel.next_event(cx).await, @@ -307,30 +277,28 @@ async fn test_channel_messages(cx: &mut TestAppContext) { let get_messages = server.receive::().await.unwrap(); assert_eq!(get_messages.payload.channel_id, 5); assert_eq!(get_messages.payload.before_message_id, 10); - server - .respond( - get_messages.receipt(), - proto::GetChannelMessagesResponse { - done: true, - messages: vec![ - proto::ChannelMessage { - id: 8, - body: "y".into(), - timestamp: 998, - sender_id: 5, - nonce: Some(4.into()), - }, - proto::ChannelMessage { - id: 9, - body: "z".into(), - timestamp: 999, - sender_id: 6, - nonce: Some(5.into()), - }, - ], - }, - ) - .await; + server.respond( + get_messages.receipt(), + proto::GetChannelMessagesResponse { + done: true, + messages: vec![ + proto::ChannelMessage { + id: 8, + body: "y".into(), + timestamp: 998, + sender_id: 5, + nonce: Some(4.into()), + }, + proto::ChannelMessage { + id: 9, + body: "z".into(), + timestamp: 999, + sender_id: 6, + nonce: Some(5.into()), + }, + ], + }, + ); assert_eq!( channel.next_event(cx).await, @@ -353,6 +321,19 @@ async fn test_channel_messages(cx: &mut TestAppContext) { }); } +fn init_test(cx: &mut AppContext) -> ModelHandle { + let http = FakeHttpClient::with_404_response(); + let client = Client::new(http.clone(), cx); + let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); + + cx.foreground().forbid_parking(); + cx.set_global(SettingsStore::test(cx)); + crate::init(&client); + client::init(&client, cx); + + cx.add_model(|cx| ChannelStore::new(client, user_store, cx)) +} + fn update_channels( channel_store: &ModelHandle, message: proto::UpdateChannels, diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index 00e7cd1508613c60a05ddbba8cabff86bbaf1d14..38cd12f21cdf74ed895ff9d55ae1995e2efcfafa 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -170,8 +170,7 @@ impl FakeServer { staff: false, flags: Default::default(), }, - ) - .await; + ); continue; } @@ -182,11 +181,7 @@ impl FakeServer { } } - pub async fn respond( - &self, - receipt: Receipt, - response: T::Response, - ) { + pub fn respond(&self, receipt: Receipt, response: T::Response) { self.peer.respond(receipt, response).unwrap() } diff --git a/crates/collab/src/db/queries/messages.rs b/crates/collab/src/db/queries/messages.rs index e8b9ec74165efa388b2af7c3a27d813278a0f461..4593d8f2f3717ce6e9894220095b43a688e3a379 100644 --- a/crates/collab/src/db/queries/messages.rs +++ b/crates/collab/src/db/queries/messages.rs @@ -82,7 +82,7 @@ impl Database { id: row.id.to_proto(), sender_id: row.sender_id.to_proto(), body: row.body, - timestamp: row.sent_at.unix_timestamp() as u64, + timestamp: row.sent_at.assume_utc().unix_timestamp() as u64, nonce: Some(proto::Nonce { upper_half: nonce.0, lower_half: nonce.1, @@ -124,6 +124,9 @@ impl Database { Err(anyhow!("not a chat participant"))?; } + let timestamp = timestamp.to_offset(time::UtcOffset::UTC); + let timestamp = time::PrimitiveDateTime::new(timestamp.date(), timestamp.time()); + let message = channel_message::Entity::insert(channel_message::ActiveModel { channel_id: ActiveValue::Set(channel_id), sender_id: ActiveValue::Set(user_id), diff --git a/crates/collab/src/db/tables/channel_message.rs b/crates/collab/src/db/tables/channel_message.rs index d043d5b6686fdd1b56581217cf63b6187fce4738..ff49c63ba71d675f20f542d3300d74d322d70722 100644 --- a/crates/collab/src/db/tables/channel_message.rs +++ b/crates/collab/src/db/tables/channel_message.rs @@ -1,6 +1,6 @@ use crate::db::{ChannelId, MessageId, UserId}; use sea_orm::entity::prelude::*; -use time::OffsetDateTime; +use time::PrimitiveDateTime; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "channel_messages")] @@ -10,7 +10,7 @@ pub struct Model { pub channel_id: ChannelId, pub sender_id: UserId, pub body: String, - pub sent_at: OffsetDateTime, + pub sent_at: PrimitiveDateTime, pub nonce: Uuid, } diff --git a/crates/collab/src/db/tests/message_tests.rs b/crates/collab/src/db/tests/message_tests.rs index 1dd686a1d28534b8e71a6d2b700a8cdd0c467777..c40e53d3559e4e65ab68e5fb061901418c02ab3f 100644 --- a/crates/collab/src/db/tests/message_tests.rs +++ b/crates/collab/src/db/tests/message_tests.rs @@ -30,6 +30,12 @@ async fn test_channel_message_nonces(db: &Arc) { .await .unwrap(); + let owner_id = db.create_server("test").await.unwrap().0 as u32; + + db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user) + .await + .unwrap(); + let msg1_id = db .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1) .await diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 0105a9d27b2fa67d34b660b7cf18640169286bd7..1cbd8574ebbc112d533f4efcdbabb5d20e00b5c3 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -1,21 +1,33 @@ +use crate::collab_panel::{CollaborationPanelDockPosition, CollaborationPanelSettings}; +use anyhow::Result; use channel::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelStore}; use client::Client; +use db::kvp::KEY_VALUE_STORE; use editor::Editor; use gpui::{ actions, elements::*, platform::{CursorStyle, MouseButton}, + serde_json, views::{ItemType, Select, SelectStyle}, - AnyViewHandle, AppContext, Entity, ModelHandle, Subscription, View, ViewContext, ViewHandle, + AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, + ViewContext, ViewHandle, WeakViewHandle, }; use language::language_settings::SoftWrap; use menu::Confirm; +use project::Fs; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use theme::Theme; use time::{OffsetDateTime, UtcOffset}; use util::{ResultExt, TryFutureExt}; +use workspace::{ + dock::{DockPosition, Panel}, + Workspace, +}; const MESSAGE_LOADING_THRESHOLD: usize = 50; +const CHAT_PANEL_KEY: &'static str = "ChatPanel"; pub struct ChatPanel { client: Arc, @@ -25,11 +37,25 @@ pub struct ChatPanel { input_editor: ViewHandle, channel_select: ViewHandle, @@ -105,7 +105,7 @@ impl ChatPanel { let mut message_list = ListState::::new(0, Orientation::Bottom, 1000., move |this, ix, cx| { - let message = this.active_channel.as_ref().unwrap().0.read(cx).message(ix); + let message = this.active_chat.as_ref().unwrap().0.read(cx).message(ix); this.render_message(message, cx) }); message_list.set_scroll_handler(|visible_range, this, cx| { @@ -119,7 +119,7 @@ impl ChatPanel { fs, client, channel_store, - active_channel: Default::default(), + active_chat: Default::default(), pending_serialization: Task::ready(None), message_list, input_editor, @@ -151,6 +151,7 @@ impl ChatPanel { cx.observe(&this.channel_select, |this, channel_select, cx| { let selected_ix = channel_select.read(cx).selected_index(); + let selected_channel_id = this .channel_store .read(cx) @@ -216,14 +217,14 @@ impl ChatPanel { fn init_active_channel(&mut self, cx: &mut ViewContext) { let channel_count = self.channel_store.read(cx).channel_count(); self.message_list.reset(0); - self.active_channel = None; + self.active_chat = None; self.channel_select.update(cx, |select, cx| { select.set_item_count(channel_count, cx); }); } - fn set_active_channel(&mut self, chat: ModelHandle, cx: &mut ViewContext) { - if self.active_channel.as_ref().map(|e| &e.0) != Some(&chat) { + fn set_active_chat(&mut self, chat: ModelHandle, cx: &mut ViewContext) { + if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) { let id = chat.read(cx).channel().id; { let chat = chat.read(cx); @@ -234,7 +235,7 @@ impl ChatPanel { }); } let subscription = cx.subscribe(&chat, Self::channel_did_change); - self.active_channel = Some((chat, subscription)); + self.active_chat = Some((chat, subscription)); self.channel_select.update(cx, |select, cx| { if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) { select.set_selected_index(ix, cx); @@ -275,7 +276,7 @@ impl ChatPanel { } fn render_active_channel_messages(&self) -> AnyElement { - let messages = if self.active_channel.is_some() { + let messages = if self.active_chat.is_some() { List::new(self.message_list.clone()).into_any() } else { Empty::new().into_any() @@ -396,15 +397,15 @@ impl ChatPanel { } fn send(&mut self, _: &Confirm, cx: &mut ViewContext) { - if let Some((channel, _)) = self.active_channel.as_ref() { + if let Some((chat, _)) = self.active_chat.as_ref() { let body = self.input_editor.update(cx, |editor, cx| { let body = editor.text(cx); editor.clear(cx); body }); - if let Some(task) = channel - .update(cx, |channel, cx| channel.send_message(body, cx)) + if let Some(task) = chat + .update(cx, |chat, cx| chat.send_message(body, cx)) .log_err() { task.detach(); @@ -413,8 +414,8 @@ impl ChatPanel { } fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext) { - if let Some((channel, _)) = self.active_channel.as_ref() { - channel.update(cx, |channel, cx| { + if let Some((chat, _)) = self.active_chat.as_ref() { + chat.update(cx, |channel, cx| { channel.load_more_messages(cx); }) } @@ -425,13 +426,19 @@ impl ChatPanel { selected_channel_id: u64, cx: &mut ViewContext, ) -> Task> { + if let Some((chat, _)) = &self.active_chat { + if chat.read(cx).channel().id == selected_channel_id { + return Task::ready(Ok(())); + } + } + let open_chat = self.channel_store.update(cx, |store, cx| { store.open_channel_chat(selected_channel_id, cx) }); cx.spawn(|this, mut cx| async move { let chat = open_chat.await?; this.update(&mut cx, |this, cx| { - this.set_active_channel(chat, cx); + this.set_active_chat(chat, cx); }) }) } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 396ff6df9157ba860468b86976f35aaee1fbfb1f..ad1c2a0037176615ca6911df5db6e5eec1a26ae2 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -80,7 +80,7 @@ struct RenameChannel { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -struct OpenChannelBuffer { +struct OpenChannelNotes { channel_id: u64, } @@ -104,7 +104,7 @@ impl_actions!( ManageMembers, RenameChannel, ToggleCollapse, - OpenChannelBuffer + OpenChannelNotes ] ); @@ -130,7 +130,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(CollabPanel::toggle_channel_collapsed); cx.add_action(CollabPanel::collapse_selected_channel); cx.add_action(CollabPanel::expand_selected_channel); - cx.add_action(CollabPanel::open_channel_buffer); + cx.add_action(CollabPanel::open_channel_notes); } #[derive(Debug)] @@ -226,10 +226,6 @@ enum ListEntry { channel: Arc, depth: usize, }, - ChannelCall { - channel: Arc, - depth: usize, - }, ChannelNotes { channel_id: ChannelId, }, @@ -369,13 +365,6 @@ impl CollabPanel { return channel_row; } } - ListEntry::ChannelCall { channel, depth } => this.render_channel_call( - &*channel, - *depth, - &theme.collab_panel, - is_selected, - cx, - ), ListEntry::ChannelNotes { channel_id } => this.render_channel_notes( *channel_id, &theme.collab_panel, @@ -751,12 +740,6 @@ impl CollabPanel { channel: channel.clone(), depth, }); - if !channel_store.channel_participants(channel.id).is_empty() { - self.entries.push(ListEntry::ChannelCall { - channel: channel.clone(), - depth, - }); - } } } } @@ -1566,10 +1549,23 @@ impl CollabPanel { let disclosed = has_children.then(|| !self.collapsed_channels.binary_search(&channel_id).is_ok()); + let is_active = iife!({ + let call_channel = ActiveCall::global(cx) + .read(cx) + .room()? + .read(cx) + .channel_id()?; + Some(call_channel == channel_id) + }) + .unwrap_or(false); + + const FACEPILE_LIMIT: usize = 3; + enum ChannelCall {} - enum ChannelNotes {} MouseEventHandler::new::(channel.id as usize, cx, |state, cx| { + let row_hovered = state.hovered(); + Flex::::row() .with_child( Svg::new("icons/hash.svg") @@ -1588,29 +1584,49 @@ impl CollabPanel { .flex(1., true), ) .with_child( - MouseEventHandler::new::(channel_id as usize, cx, |_, _| { - Svg::new("icons/radix/speaker-loud.svg") - .with_color(theme.channel_hash.color) - .constrained() - .with_width(theme.channel_hash.width) - .aligned() - .right() - }) - .on_click(MouseButton::Left, move |_, this, cx| { - this.join_channel_call(channel_id, cx) - }), - ) - .with_child( - MouseEventHandler::new::(channel_id as usize, cx, |_, _| { - Svg::new("icons/radix/file.svg") - .with_color(theme.channel_hash.color) - .constrained() - .with_width(theme.channel_hash.width) - .aligned() - .right() - }) + MouseEventHandler::new::( + channel.id as usize, + cx, + move |_, cx| { + let participants = + self.channel_store.read(cx).channel_participants(channel_id); + if !participants.is_empty() { + let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT); + + FacePile::new(theme.face_overlap) + .with_children( + participants + .iter() + .filter_map(|user| { + Some( + Image::from_data(user.avatar.clone()?) + .with_style(theme.channel_avatar), + ) + }) + .take(FACEPILE_LIMIT), + ) + .with_children((extra_count > 0).then(|| { + Label::new( + format!("+{}", extra_count), + theme.extra_participant_label.text.clone(), + ) + .contained() + .with_style(theme.extra_participant_label.container) + })) + .into_any() + } else if row_hovered { + Svg::new("icons/radix/speaker-loud.svg") + .with_color(theme.channel_hash.color) + .constrained() + .with_width(theme.channel_hash.width) + .into_any() + } else { + Empty::new().into_any() + } + }, + ) .on_click(MouseButton::Left, move |_, this, cx| { - this.open_channel_buffer(&OpenChannelBuffer { channel_id }, cx); + this.join_channel_call(channel_id, cx); }), ) .align_children_center() @@ -1622,7 +1638,7 @@ impl CollabPanel { .constrained() .with_height(theme.row_height) .contained() - .with_style(*theme.channel_row.style_for(is_selected, state)) + .with_style(*theme.channel_row.style_for(is_selected || is_active, state)) .with_padding_left( theme.channel_row.default_style().padding.left + theme.channel_indent * depth as f32, @@ -1638,94 +1654,6 @@ impl CollabPanel { .into_any() } - fn render_channel_call( - &self, - channel: &Channel, - depth: usize, - theme: &theme::CollabPanel, - is_selected: bool, - cx: &mut ViewContext, - ) -> AnyElement { - let channel_id = channel.id; - - let is_active = iife!({ - let call_channel = ActiveCall::global(cx) - .read(cx) - .room()? - .read(cx) - .channel_id()?; - Some(call_channel == channel_id) - }) - .unwrap_or(false); - - const FACEPILE_LIMIT: usize = 5; - - enum ChannelCall {} - - let host_avatar_width = theme - .contact_avatar - .width - .or(theme.contact_avatar.height) - .unwrap_or(0.); - - MouseEventHandler::new::(channel.id as usize, cx, |state, cx| { - let participants = self.channel_store.read(cx).channel_participants(channel_id); - let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT); - let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state); - let row = theme.project_row.in_state(is_selected).style_for(state); - - Flex::::row() - .with_child(render_tree_branch( - tree_branch, - &row.name.text, - true, - vec2f(host_avatar_width, theme.row_height), - cx.font_cache(), - )) - .with_child( - FacePile::new(theme.face_overlap) - .with_children( - participants - .iter() - .filter_map(|user| { - Some( - Image::from_data(user.avatar.clone()?) - .with_style(theme.channel_avatar), - ) - }) - .take(FACEPILE_LIMIT), - ) - .with_children((extra_count > 0).then(|| { - Label::new( - format!("+{}", extra_count), - theme.extra_participant_label.text.clone(), - ) - .contained() - .with_style(theme.extra_participant_label.container) - })), - ) - .align_children_center() - .constrained() - .with_height(theme.row_height) - .aligned() - .left() - .contained() - .with_style(*theme.channel_row.style_for(is_selected || is_active, state)) - .with_padding_left( - theme.channel_row.default_style().padding.left - + theme.channel_indent * (depth + 1) as f32, - ) - }) - .on_click(MouseButton::Left, move |_, this, cx| { - this.join_channel_call(channel_id, cx); - }) - .on_click(MouseButton::Right, move |e, this, cx| { - this.deploy_channel_context_menu(Some(e.position), channel_id, cx); - }) - .with_cursor_style(CursorStyle::PointingHand) - .into_any() - } - fn render_channel_notes( &self, channel_id: ChannelId, @@ -1775,7 +1703,7 @@ impl CollabPanel { .with_padding_left(theme.channel_row.default_style().padding.left) }) .on_click(MouseButton::Left, move |_, this, cx| { - this.open_channel_buffer(&OpenChannelBuffer { channel_id }, cx); + this.open_channel_notes(&OpenChannelNotes { channel_id }, cx); }) .with_cursor_style(CursorStyle::PointingHand) .into_any() @@ -1987,7 +1915,7 @@ impl CollabPanel { let mut items = vec![ ContextMenuItem::action(expand_action_name, ToggleCollapse { channel_id }), - ContextMenuItem::action("Open Notes", OpenChannelBuffer { channel_id }), + ContextMenuItem::action("Open Notes", OpenChannelNotes { channel_id }), ]; if self.channel_store.read(cx).is_user_admin(channel_id) { @@ -2114,9 +2042,6 @@ impl CollabPanel { ListEntry::Channel { channel, .. } => { self.join_channel_chat(channel.id, cx); } - ListEntry::ChannelCall { channel, .. } => { - self.join_channel_call(channel.id, cx); - } ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx), _ => {} } @@ -2325,7 +2250,7 @@ impl CollabPanel { } } - fn open_channel_buffer(&mut self, action: &OpenChannelBuffer, cx: &mut ViewContext) { + fn open_channel_notes(&mut self, action: &OpenChannelNotes, cx: &mut ViewContext) { if let Some(workspace) = self.workspace.upgrade(cx) { let pane = workspace.read(cx).active_pane().clone(); let channel_id = action.channel_id; @@ -2510,7 +2435,9 @@ impl CollabPanel { .detach_and_log_err(cx); } - fn join_channel_chat(&self, channel_id: u64, cx: &mut ViewContext) { + fn join_channel_chat(&mut self, channel_id: u64, cx: &mut ViewContext) { + self.open_channel_notes(&OpenChannelNotes { channel_id }, cx); + if let Some(workspace) = self.workspace.upgrade(cx) { cx.app_context().defer(move |cx| { workspace.update(cx, |workspace, cx| { @@ -2757,18 +2684,6 @@ impl PartialEq for ListEntry { return channel_1.id == channel_2.id && depth_1 == depth_2; } } - ListEntry::ChannelCall { - channel: channel_1, - depth: depth_1, - } => { - if let ListEntry::ChannelCall { - channel: channel_2, - depth: depth_2, - } = other - { - return channel_1.id == channel_2.id && depth_1 == depth_2; - } - } ListEntry::ChannelNotes { channel_id } => { if let ListEntry::ChannelNotes { channel_id: other_id, From f2112b9aad44fab5092d9f640d3617955b470419 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 11 Sep 2023 17:11:33 -0700 Subject: [PATCH 09/19] Rejoin channel chats upon reconnecting --- crates/channel/src/channel_store.rs | 10 +++ .../collab/src/tests/channel_message_tests.rs | 85 ++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index b90a3fdaeb5577eff3be6db5fa79147e69f87078..e61e520b471029e692ecdc29bac58777917d92ba 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -532,6 +532,16 @@ impl ChannelStore { fn handle_connect(&mut self, cx: &mut ModelContext) -> Task> { self.disconnect_channel_buffers_task.take(); + for chat in self.opened_chats.values() { + if let OpenedModelHandle::Open(chat) = chat { + if let Some(chat) = chat.upgrade(cx) { + chat.update(cx, |chat, cx| { + chat.rejoin(cx); + }); + } + } + } + let mut buffer_versions = Vec::new(); for buffer in self.opened_buffers.values() { if let OpenedModelHandle::Open(buffer) = buffer { diff --git a/crates/collab/src/tests/channel_message_tests.rs b/crates/collab/src/tests/channel_message_tests.rs index e7afd136f36cfdf5d3be4eb8c9a79b49c0f6fa89..f74416fa7d8429cd0703c9e010e608322f87a96b 100644 --- a/crates/collab/src/tests/channel_message_tests.rs +++ b/crates/collab/src/tests/channel_message_tests.rs @@ -1,5 +1,6 @@ -use crate::tests::TestServer; -use gpui::{executor::Deterministic, TestAppContext}; +use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; +use channel::ChannelChat; +use gpui::{executor::Deterministic, ModelHandle, TestAppContext}; use std::sync::Arc; #[gpui::test] @@ -54,3 +55,83 @@ async fn test_basic_channel_messages( ); }) } + +#[gpui::test] +async fn test_rejoin_channel_chat( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel("the-channel", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + let channel_chat_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + let channel_chat_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + + channel_chat_a + .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap()) + .await + .unwrap(); + channel_chat_b + .update(cx_b, |c, cx| c.send_message("two".into(), cx).unwrap()) + .await + .unwrap(); + + server.forbid_connections(); + server.disconnect_client(client_a.peer_id().unwrap()); + + // While client A is disconnected, clients A and B both send new messages. + channel_chat_a + .update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap()) + .await + .unwrap_err(); + channel_chat_a + .update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap()) + .await + .unwrap_err(); + channel_chat_b + .update(cx_b, |c, cx| c.send_message("five".into(), cx).unwrap()) + .await + .unwrap(); + channel_chat_b + .update(cx_b, |c, cx| c.send_message("six".into(), cx).unwrap()) + .await + .unwrap(); + + // Client A reconnects. + server.allow_connections(); + deterministic.advance_clock(RECONNECT_TIMEOUT); + + // Client A fetches the messages that were sent while they were disconnected + // and resends their own messages which failed to send. + let expected_messages = &["one", "two", "five", "six", "three", "four"]; + assert_messages(&channel_chat_a, expected_messages, cx_a); + assert_messages(&channel_chat_b, expected_messages, cx_b); +} + +#[track_caller] +fn assert_messages(chat: &ModelHandle, messages: &[&str], cx: &mut TestAppContext) { + chat.update(cx, |chat, _| { + assert_eq!( + chat.messages() + .iter() + .map(|m| m.body.as_str()) + .collect::>(), + messages + ); + }) +} From 1c50587cad882e7fe751720fd4d7aee1b3dc4740 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 11 Sep 2023 17:37:05 -0700 Subject: [PATCH 10/19] Remove channel chat participant when connection is lost --- crates/collab/src/db/queries/buffers.rs | 23 ++++++++ crates/collab/src/db/queries/messages.rs | 19 ++++++ crates/collab/src/db/queries/rooms.rs | 73 ++++++++++-------------- crates/collab/src/rpc.rs | 3 +- 4 files changed, 74 insertions(+), 44 deletions(-) diff --git a/crates/collab/src/db/queries/buffers.rs b/crates/collab/src/db/queries/buffers.rs index 00de20140320640d98517556a5321433ad891f86..62ead11932af5cdab9049d719eef7b1238b73ffc 100644 --- a/crates/collab/src/db/queries/buffers.rs +++ b/crates/collab/src/db/queries/buffers.rs @@ -249,6 +249,29 @@ impl Database { .await } + pub async fn channel_buffer_connection_lost( + &self, + connection: ConnectionId, + tx: &DatabaseTransaction, + ) -> Result<()> { + channel_buffer_collaborator::Entity::update_many() + .filter( + Condition::all() + .add(channel_buffer_collaborator::Column::ConnectionId.eq(connection.id as i32)) + .add( + channel_buffer_collaborator::Column::ConnectionServerId + .eq(connection.owner_id as i32), + ), + ) + .set(channel_buffer_collaborator::ActiveModel { + connection_lost: ActiveValue::set(true), + ..Default::default() + }) + .exec(&*tx) + .await?; + Ok(()) + } + pub async fn leave_channel_buffers( &self, connection: ConnectionId, diff --git a/crates/collab/src/db/queries/messages.rs b/crates/collab/src/db/queries/messages.rs index 4593d8f2f3717ce6e9894220095b43a688e3a379..60bb245207718ceb2a1744d060bbb57e20831e20 100644 --- a/crates/collab/src/db/queries/messages.rs +++ b/crates/collab/src/db/queries/messages.rs @@ -25,6 +25,25 @@ impl Database { .await } + pub async fn channel_chat_connection_lost( + &self, + connection_id: ConnectionId, + tx: &DatabaseTransaction, + ) -> Result<()> { + channel_chat_participant::Entity::delete_many() + .filter( + Condition::all() + .add( + channel_chat_participant::Column::ConnectionServerId + .eq(connection_id.owner_id), + ) + .add(channel_chat_participant::Column::ConnectionId.eq(connection_id.id)), + ) + .exec(tx) + .await?; + Ok(()) + } + pub async fn leave_channel_chat( &self, channel_id: ChannelId, diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index e348b50beeedb47c609d455ad759f59adea01adf..fb81fef176ab8bfa38ece1b4beb284088a96e0bb 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -890,54 +890,43 @@ impl Database { pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> { self.transaction(|tx| async move { - let participant = room_participant::Entity::find() - .filter( - Condition::all() - .add( - room_participant::Column::AnsweringConnectionId - .eq(connection.id as i32), - ) - .add( - room_participant::Column::AnsweringConnectionServerId - .eq(connection.owner_id as i32), - ), - ) - .one(&*tx) - .await?; - - if let Some(participant) = participant { - room_participant::Entity::update(room_participant::ActiveModel { - answering_connection_lost: ActiveValue::set(true), - ..participant.into_active_model() - }) - .exec(&*tx) + self.room_connection_lost(connection, &*tx).await?; + self.channel_buffer_connection_lost(connection, &*tx) .await?; - } - - channel_buffer_collaborator::Entity::update_many() - .filter( - Condition::all() - .add( - channel_buffer_collaborator::Column::ConnectionId - .eq(connection.id as i32), - ) - .add( - channel_buffer_collaborator::Column::ConnectionServerId - .eq(connection.owner_id as i32), - ), - ) - .set(channel_buffer_collaborator::ActiveModel { - connection_lost: ActiveValue::set(true), - ..Default::default() - }) - .exec(&*tx) - .await?; - + self.channel_chat_connection_lost(connection, &*tx).await?; Ok(()) }) .await } + pub async fn room_connection_lost( + &self, + connection: ConnectionId, + tx: &DatabaseTransaction, + ) -> Result<()> { + let participant = room_participant::Entity::find() + .filter( + Condition::all() + .add(room_participant::Column::AnsweringConnectionId.eq(connection.id as i32)) + .add( + room_participant::Column::AnsweringConnectionServerId + .eq(connection.owner_id as i32), + ), + ) + .one(&*tx) + .await?; + + if let Some(participant) = participant { + room_participant::Entity::update(room_participant::ActiveModel { + answering_connection_lost: ActiveValue::set(true), + ..participant.into_active_model() + }) + .exec(&*tx) + .await?; + } + Ok(()) + } + fn build_incoming_call( room: &proto::Room, called_user_id: UserId, diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index b508c45dc53f0a1017d44d1df96643165fc59b53..5ecd05ae1bb411f4333a61742115f619e582d231 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -904,9 +904,8 @@ async fn connection_lost( room_updated(&room, &session.peer); } } - update_user_contacts(session.user_id, &session).await?; - + update_user_contacts(session.user_id, &session).await?; } _ = teardown.changed().fuse() => {} } From 89327eb84e42bcf9749413a59f8be21c2b0e88b5 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 14 Sep 2023 15:16:08 -0700 Subject: [PATCH 11/19] Start styling the chat panel --- styles/src/style_tree/chat_panel.ts | 90 +++++++++++------------------ 1 file changed, 34 insertions(+), 56 deletions(-) diff --git a/styles/src/style_tree/chat_panel.ts b/styles/src/style_tree/chat_panel.ts index 735c3b54dde8f169dd45ce823a6d6ddd8147a99f..53559911e726d96a37ab062de5a4d52d608acd4d 100644 --- a/styles/src/style_tree/chat_panel.ts +++ b/styles/src/style_tree/chat_panel.ts @@ -1,53 +1,21 @@ import { background, border, - border_color, - foreground, text, } from "./components" -import { interactive, toggleable } from "../element" import { useTheme } from "../theme" -import collab_modals from "./collab_modals" -import { icon_button, toggleable_icon_button } from "../component/icon_button" -import { indicator } from "../component/indicator" -export default function contacts_panel(): any { +export default function chat_panel(): any { const theme = useTheme() - - const CHANNEL_SPACING = 4 as const - const NAME_MARGIN = 6 as const - const SPACING = 12 as const - const INDENT_SIZE = 8 as const - const ITEM_HEIGHT = 28 as const - const layer = theme.middle - const input_editor = { - background: background(layer, "on"), - corner_radius: 6, - text: text(layer, "sans", "base"), - placeholder_text: text(layer, "sans", "base", "disabled", { - size: "xs", - }), - selection: theme.players[0], - border: border(layer, "on"), - padding: { - bottom: 4, - left: 8, - right: 8, - top: 4, - }, - margin: { - left: SPACING, - right: SPACING, - }, - } + const SPACING = 12 as const const channel_name = { padding: { - top: 4, + // top: 4, bottom: 4, - left: 4, + // left: 4, right: 4, }, hash: { @@ -58,8 +26,14 @@ export default function contacts_panel(): any { return { background: background(layer), + padding: { + top: SPACING, + bottom: SPACING, + left: SPACING, + right: SPACING, + }, channel_select: { - header: channel_name, + header: { ...channel_name }, item: channel_name, active_item: channel_name, hovered_item: channel_name, @@ -71,24 +45,38 @@ export default function contacts_panel(): any { } } }, - input_editor, + input_editor: { + background: background(layer, "on"), + corner_radius: 6, + text: text(layer, "sans", "base"), + placeholder_text: text(layer, "sans", "base", "disabled", { + size: "xs", + }), + selection: theme.players[0], + border: border(layer, "on"), + padding: { + bottom: 4, + left: 8, + right: 8, + top: 4, + }, + }, message: { body: text(layer, "sans", "base"), sender: { - padding: { - left: 4, - right: 4, + margin: { + right: 8, }, - ...text(layer, "sans", "base", "disabled"), + ...text(layer, "sans", "base", { weight: "bold" }), }, - timestamp: text(layer, "sans", "base"), + timestamp: text(layer, "sans", "base", "disabled"), + margin: { bottom: SPACING } }, pending_message: { body: text(layer, "sans", "base"), sender: { - padding: { - left: 4, - right: 4, + margin: { + right: 8, }, ...text(layer, "sans", "base", "disabled"), }, @@ -96,16 +84,6 @@ export default function contacts_panel(): any { }, sign_in_prompt: { default: text(layer, "sans", "base"), - }, - timestamp: { - body: text(layer, "sans", "base"), - sender: { - padding: { - left: 4, - right: 4, - }, - ...text(layer, "sans", "base", "disabled"), - } } } } From 59269d422b246ecf20fa7f8d6417bc371f117250 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 14 Sep 2023 16:22:21 -0700 Subject: [PATCH 12/19] Allow deleting chat messages --- crates/channel/src/channel_chat.rs | 46 +++++++++ crates/collab/src/db/queries/messages.rs | 40 ++++++++ crates/collab/src/rpc.rs | 20 ++++ .../collab/src/tests/channel_message_tests.rs | 97 +++++++++++++++++-- crates/collab_ui/src/chat_panel.rs | 76 ++++++++++++--- crates/rpc/proto/zed.proto | 8 +- crates/rpc/src/proto.rs | 3 + 7 files changed, 266 insertions(+), 24 deletions(-) diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index f821e2ed8e55b41174e7e7ced3b6d48f89a3c80b..8e03a3b6fd0f1d2d56ccb648525f268766a9eaf1 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -59,6 +59,7 @@ pub enum ChannelChatEvent { pub fn init(client: &Arc) { client.add_model_message_handler(ChannelChat::handle_message_sent); + client.add_model_message_handler(ChannelChat::handle_message_removed); } impl Entity for ChannelChat { @@ -166,6 +167,21 @@ impl ChannelChat { })) } + pub fn remove_message(&mut self, id: u64, cx: &mut ModelContext) -> Task> { + let response = self.rpc.request(proto::RemoveChannelMessage { + channel_id: self.channel.id, + message_id: id, + }); + cx.spawn(|this, mut cx| async move { + response.await?; + + this.update(&mut cx, |this, cx| { + this.message_removed(id, cx); + Ok(()) + }) + }) + } + pub fn load_more_messages(&mut self, cx: &mut ModelContext) -> bool { if !self.loaded_all_messages { let rpc = self.rpc.clone(); @@ -306,6 +322,18 @@ impl ChannelChat { Ok(()) } + async fn handle_message_removed( + this: ModelHandle, + message: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.message_removed(message.payload.message_id, cx) + }); + Ok(()) + } + fn insert_messages(&mut self, messages: SumTree, cx: &mut ModelContext) { if let Some((first_message, last_message)) = messages.first().zip(messages.last()) { let nonces = messages @@ -363,6 +391,24 @@ impl ChannelChat { cx.notify(); } } + + fn message_removed(&mut self, id: u64, cx: &mut ModelContext) { + let mut cursor = self.messages.cursor::(); + let mut messages = cursor.slice(&ChannelMessageId::Saved(id), Bias::Left, &()); + if let Some(item) = cursor.item() { + if item.id == ChannelMessageId::Saved(id) { + let ix = messages.summary().count; + cursor.next(&()); + messages.append(cursor.suffix(&()), &()); + drop(cursor); + self.messages = messages; + cx.emit(ChannelChatEvent::MessagesUpdated { + old_range: ix..ix + 1, + new_count: 0, + }); + } + } + } } async fn messages_from_proto( diff --git a/crates/collab/src/db/queries/messages.rs b/crates/collab/src/db/queries/messages.rs index 60bb245207718ceb2a1744d060bbb57e20831e20..0b88df6716c0068755e0345513d50784386d2e82 100644 --- a/crates/collab/src/db/queries/messages.rs +++ b/crates/collab/src/db/queries/messages.rs @@ -171,4 +171,44 @@ impl Database { }) .await } + + pub async fn remove_channel_message( + &self, + channel_id: ChannelId, + message_id: MessageId, + user_id: UserId, + ) -> Result> { + self.transaction(|tx| async move { + let mut rows = channel_chat_participant::Entity::find() + .filter(channel_chat_participant::Column::ChannelId.eq(channel_id)) + .stream(&*tx) + .await?; + + let mut is_participant = false; + let mut participant_connection_ids = Vec::new(); + while let Some(row) = rows.next().await { + let row = row?; + if row.user_id == user_id { + is_participant = true; + } + participant_connection_ids.push(row.connection()); + } + drop(rows); + + if !is_participant { + Err(anyhow!("not a chat participant"))?; + } + + let result = channel_message::Entity::delete_by_id(message_id) + .filter(channel_message::Column::SenderId.eq(user_id)) + .exec(&*tx) + .await?; + if result.rows_affected == 0 { + Err(anyhow!("no such message"))?; + } + + Ok(participant_connection_ids) + }) + .await + } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 549b98376f49951d6a75142bf2fc11d716af4be7..3289daf6ca3feb4a75ada7f60ca27f56fbd80528 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -265,6 +265,7 @@ impl Server { .add_request_handler(join_channel_chat) .add_message_handler(leave_channel_chat) .add_request_handler(send_channel_message) + .add_request_handler(remove_channel_message) .add_request_handler(get_channel_messages) .add_request_handler(follow) .add_message_handler(unfollow) @@ -2696,6 +2697,25 @@ async fn send_channel_message( Ok(()) } +async fn remove_channel_message( + request: proto::RemoveChannelMessage, + response: Response, + session: Session, +) -> Result<()> { + let channel_id = ChannelId::from_proto(request.channel_id); + let message_id = MessageId::from_proto(request.message_id); + let connection_ids = session + .db() + .await + .remove_channel_message(channel_id, message_id, session.user_id) + .await?; + broadcast(Some(session.connection_id), connection_ids, |connection| { + session.peer.send(connection, request.clone()) + }); + response.send(proto::Ack {})?; + Ok(()) +} + async fn join_channel_chat( request: proto::JoinChannelChat, response: Response, diff --git a/crates/collab/src/tests/channel_message_tests.rs b/crates/collab/src/tests/channel_message_tests.rs index f74416fa7d8429cd0703c9e010e608322f87a96b..1a9460c6cfb00f931ef4d0fb4ea67dc690135dd7 100644 --- a/crates/collab/src/tests/channel_message_tests.rs +++ b/crates/collab/src/tests/channel_message_tests.rs @@ -1,5 +1,5 @@ use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; -use channel::ChannelChat; +use channel::{ChannelChat, ChannelMessageId}; use gpui::{executor::Deterministic, ModelHandle, TestAppContext}; use std::sync::Arc; @@ -123,15 +123,92 @@ async fn test_rejoin_channel_chat( assert_messages(&channel_chat_b, expected_messages, cx_b); } +#[gpui::test] +async fn test_remove_channel_message( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + + let channel_id = server + .make_channel( + "the-channel", + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; + + let channel_chat_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + let channel_chat_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + + // Client A sends some messages. + channel_chat_a + .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap()) + .await + .unwrap(); + channel_chat_a + .update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap()) + .await + .unwrap(); + channel_chat_a + .update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap()) + .await + .unwrap(); + + // Clients A and B see all of the messages. + deterministic.run_until_parked(); + let expected_messages = &["one", "two", "three"]; + assert_messages(&channel_chat_a, expected_messages, cx_a); + assert_messages(&channel_chat_b, expected_messages, cx_b); + + // Client A deletes one of their messages. + channel_chat_a + .update(cx_a, |c, cx| { + let ChannelMessageId::Saved(id) = c.message(1).id else { + panic!("message not saved") + }; + c.remove_message(id, cx) + }) + .await + .unwrap(); + + // Client B sees that the message is gone. + deterministic.run_until_parked(); + let expected_messages = &["one", "three"]; + assert_messages(&channel_chat_a, expected_messages, cx_a); + assert_messages(&channel_chat_b, expected_messages, cx_b); + + // Client C joins the channel chat, and does not see the deleted message. + let channel_chat_c = client_c + .channel_store() + .update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + assert_messages(&channel_chat_c, expected_messages, cx_c); +} + #[track_caller] fn assert_messages(chat: &ModelHandle, messages: &[&str], cx: &mut TestAppContext) { - chat.update(cx, |chat, _| { - assert_eq!( - chat.messages() - .iter() - .map(|m| m.body.as_str()) - .collect::>(), - messages - ); - }) + assert_eq!( + chat.read_with(cx, |chat, _| chat + .messages() + .iter() + .map(|m| m.body.clone()) + .collect::>(),), + messages + ); } diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index ba0d196e8dc27a792e5ed76619a3fea11f209a81..bbd6094d03b59c6d98fde9bea598f7736b0154ee 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -1,6 +1,6 @@ use crate::ChatPanelSettings; use anyhow::Result; -use channel::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelStore}; +use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; use client::Client; use db::kvp::KEY_VALUE_STORE; use editor::Editor; @@ -19,7 +19,7 @@ use project::Fs; use serde::{Deserialize, Serialize}; use settings::SettingsStore; use std::sync::Arc; -use theme::Theme; +use theme::{IconButton, Theme}; use time::{OffsetDateTime, UtcOffset}; use util::{ResultExt, TryFutureExt}; use workspace::{ @@ -105,8 +105,7 @@ impl ChatPanel { let mut message_list = ListState::::new(0, Orientation::Bottom, 1000., move |this, ix, cx| { - let message = this.active_chat.as_ref().unwrap().0.read(cx).message(ix); - this.render_message(message, cx) + this.render_message(ix, cx) }); message_list.set_scroll_handler(|visible_range, this, cx| { if visible_range.start < MESSAGE_LOADING_THRESHOLD { @@ -285,38 +284,70 @@ impl ChatPanel { messages.flex(1., true).into_any() } - fn render_message(&self, message: &ChannelMessage, cx: &AppContext) -> AnyElement { + fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let message = self.active_chat.as_ref().unwrap().0.read(cx).message(ix); + let now = OffsetDateTime::now_utc(); let theme = theme::current(cx); - let theme = if message.is_pending() { + let style = if message.is_pending() { &theme.chat_panel.pending_message } else { &theme.chat_panel.message }; + let belongs_to_user = Some(message.sender.id) == self.client.user_id(); + let message_id_to_remove = + if let (ChannelMessageId::Saved(id), true) = (message.id, belongs_to_user) { + Some(id) + } else { + None + }; + + enum DeleteMessage {} + + let body = message.body.clone(); Flex::column() .with_child( Flex::row() .with_child( Label::new( message.sender.github_login.clone(), - theme.sender.text.clone(), + style.sender.text.clone(), ) .contained() - .with_style(theme.sender.container), + .with_style(style.sender.container), ) .with_child( Label::new( format_timestamp(message.timestamp, now, self.local_timezone), - theme.timestamp.text.clone(), + style.timestamp.text.clone(), ) .contained() - .with_style(theme.timestamp.container), - ), + .with_style(style.timestamp.container), + ) + .with_children(message_id_to_remove.map(|id| { + MouseEventHandler::new::( + id as usize, + cx, + |mouse_state, _| { + let button_style = + theme.collab_panel.contact_button.style_for(mouse_state); + render_icon_button(button_style, "icons/x.svg") + .aligned() + .into_any() + }, + ) + .with_padding(Padding::uniform(2.)) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.remove_message(id, cx); + }) + .flex_float() + })), ) - .with_child(Text::new(message.body.clone(), theme.body.clone())) + .with_child(Text::new(body, style.body.clone())) .contained() - .with_style(theme.container) + .with_style(style.container) .into_any() } @@ -413,6 +444,12 @@ impl ChatPanel { } } + fn remove_message(&mut self, id: u64, cx: &mut ViewContext) { + if let Some((chat, _)) = self.active_chat.as_ref() { + chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach()) + } + } + fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext) { if let Some((chat, _)) = self.active_chat.as_ref() { chat.update(cx, |channel, cx| { @@ -551,3 +588,16 @@ fn format_timestamp( format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) } } + +fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { + Svg::new(svg_path) + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + .contained() + .with_style(style.container) +} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index eb26e81b9980512e0f69c6eb663390cb0da46c2f..855588a2d8507d9e0e52af3326a798e400568b08 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -164,7 +164,8 @@ message Envelope { SendChannelMessageResponse send_channel_message_response = 146; ChannelMessageSent channel_message_sent = 147; GetChannelMessages get_channel_messages = 148; - GetChannelMessagesResponse get_channel_messages_response = 149; // Current max + GetChannelMessagesResponse get_channel_messages_response = 149; + RemoveChannelMessage remove_channel_message = 150; // Current max } } @@ -1049,6 +1050,11 @@ message SendChannelMessage { Nonce nonce = 3; } +message RemoveChannelMessage { + uint64 channel_id = 1; + uint64 message_id = 2; +} + message SendChannelMessageResponse { ChannelMessage message = 1; } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 7b98f50e75c4d47bf84ef751c143a05b4508198c..240daed1b2c3c53d5a7ca4934f4037c9f7657f32 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -217,6 +217,7 @@ messages!( (RejoinRoomResponse, Foreground), (RemoveContact, Foreground), (RemoveChannelMember, Foreground), + (RemoveChannelMessage, Foreground), (ReloadBuffers, Foreground), (ReloadBuffersResponse, Foreground), (RemoveProjectCollaborator, Foreground), @@ -327,6 +328,7 @@ request_messages!( (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), (RemoveChannel, Ack), + (RemoveChannelMessage, Ack), (RenameProjectEntry, ProjectEntryResponse), (RenameChannel, ChannelResponse), (SaveBuffer, BufferSaved), @@ -402,6 +404,7 @@ entity_messages!( ChannelMessageSent, UpdateChannelBuffer, RemoveChannelBufferCollaborator, + RemoveChannelMessage, AddChannelBufferCollaborator, UpdateChannelBufferCollaborator ); From dd7c68704111e7d09ecaeac87df4a082b0293c58 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 14 Sep 2023 17:19:08 -0700 Subject: [PATCH 13/19] Style the chat panel further --- crates/collab_ui/src/chat_panel.rs | 13 +++++--- crates/gpui/src/views/select.rs | 6 +++- crates/theme/src/theme.rs | 1 + styles/src/style_tree/chat_panel.ts | 47 +++++++++++++++++++---------- 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index bbd6094d03b59c6d98fde9bea598f7736b0154ee..afd293422f59ce205438a88d927a7b09033d0342 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -97,7 +97,7 @@ impl ChatPanel { .with_style(move |cx| { let style = &theme::current(cx).chat_panel.channel_select; SelectStyle { - header: style.header.container, + header: Default::default(), menu: style.menu, } }) @@ -269,14 +269,17 @@ impl ChatPanel { .contained() .with_style(theme.chat_panel.channel_select.container), ) - .with_child(self.render_active_channel_messages()) + .with_child(self.render_active_channel_messages(&theme)) .with_child(self.render_input_box(&theme, cx)) .into_any() } - fn render_active_channel_messages(&self) -> AnyElement { + fn render_active_channel_messages(&self, theme: &Arc) -> AnyElement { let messages = if self.active_chat.is_some() { - List::new(self.message_list.clone()).into_any() + List::new(self.message_list.clone()) + .contained() + .with_style(theme.chat_panel.list) + .into_any() } else { Empty::new().into_any() }; @@ -381,6 +384,8 @@ impl ChatPanel { .with_style(theme.hash.container), ) .with_child(Label::new(channel.name.clone(), theme.name.clone())) + .aligned() + .left() .contained() .with_style(theme.container) .into_any() diff --git a/crates/gpui/src/views/select.rs b/crates/gpui/src/views/select.rs index 0de535f8375730bd29a20fc155a76e4d11568107..0d57630c244d2e112158c9c9468b76cbb4e5eaa9 100644 --- a/crates/gpui/src/views/select.rs +++ b/crates/gpui/src/views/select.rs @@ -1,5 +1,7 @@ use crate::{ - elements::*, platform::MouseButton, AppContext, Entity, View, ViewContext, WeakViewHandle, + elements::*, + platform::{CursorStyle, MouseButton}, + AppContext, Entity, View, ViewContext, WeakViewHandle, }; pub struct Select { @@ -102,6 +104,7 @@ impl View for Select { .contained() .with_style(style.header) }) + .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, this, cx| { this.toggle(cx); }), @@ -128,6 +131,7 @@ impl View for Select { cx, ) }) + .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, this, cx| { this.set_selected_index(ix, cx); }) diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index a845db3ba41a41070836b6384bd696f9a32abad1..613c88d5cc8ae174a02531aa3210345107b965c7 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -629,6 +629,7 @@ pub struct IconButton { pub struct ChatPanel { #[serde(flatten)] pub container: ContainerStyle, + pub list: ContainerStyle, pub channel_select: ChannelSelect, pub input_editor: FieldEditor, pub message: ChatMessage, diff --git a/styles/src/style_tree/chat_panel.ts b/styles/src/style_tree/chat_panel.ts index 53559911e726d96a37ab062de5a4d52d608acd4d..d0e5d3c2499a376a7df98cf7bf6c54b48c4a195b 100644 --- a/styles/src/style_tree/chat_panel.ts +++ b/styles/src/style_tree/chat_panel.ts @@ -13,10 +13,10 @@ export default function chat_panel(): any { const channel_name = { padding: { - // top: 4, + left: SPACING, + right: SPACING, + top: 4, bottom: 4, - // left: 4, - right: 4, }, hash: { ...text(layer, "sans", "base"), @@ -26,23 +26,33 @@ export default function chat_panel(): any { return { background: background(layer), - padding: { - top: SPACING, - bottom: SPACING, - left: SPACING, - right: SPACING, + list: { + margin: { + left: SPACING, + right: SPACING, + } }, channel_select: { - header: { ...channel_name }, + header: { + ...channel_name, + border: border(layer, { bottom: true }) + }, item: channel_name, - active_item: channel_name, - hovered_item: channel_name, - hovered_active_item: channel_name, + active_item: { + ...channel_name, + background: background(layer, "on", "active"), + }, + hovered_item: { + ...channel_name, + background: background(layer, "on", "hovered"), + }, + hovered_active_item: { + ...channel_name, + background: background(layer, "on", "active"), + }, menu: { - padding: { - top: 10, - bottom: 10, - } + background: background(layer, "on"), + border: border(layer, { bottom: true }) } }, input_editor: { @@ -54,6 +64,11 @@ export default function chat_panel(): any { }), selection: theme.players[0], border: border(layer, "on"), + margin: { + left: SPACING, + right: SPACING, + bottom: SPACING, + }, padding: { bottom: 4, left: 8, From b75971196fc481daed108111e2be4227c54e457a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 14 Sep 2023 18:05:44 -0700 Subject: [PATCH 14/19] Add buttons for opening channel notes and joining call, in chat panel header --- crates/collab_ui/src/channel_view.rs | 25 +++++++++ crates/collab_ui/src/chat_panel.rs | 82 +++++++++++++++++++++------- crates/collab_ui/src/collab_panel.rs | 26 +-------- crates/gpui/src/views/select.rs | 6 +- crates/theme/src/theme.rs | 2 +- styles/src/style_tree/chat_panel.ts | 10 ++-- 6 files changed, 100 insertions(+), 51 deletions(-) diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 8bcfad68b3b2eceb8db69f2ec9863c94eb9f5b67..c6f32cecd271fea3e61101c4cc0d8104ad2329d3 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -1,4 +1,5 @@ use anyhow::{anyhow, Result}; +use call::ActiveCall; use channel::{ChannelBuffer, ChannelBufferEvent, ChannelId}; use client::proto; use clock::ReplicaId; @@ -35,6 +36,30 @@ pub struct ChannelView { } impl ChannelView { + pub fn deploy(channel_id: ChannelId, workspace: ViewHandle, cx: &mut AppContext) { + let pane = workspace.read(cx).active_pane().clone(); + let channel_view = Self::open(channel_id, pane.clone(), workspace.clone(), cx); + cx.spawn(|mut cx| async move { + let channel_view = channel_view.await?; + pane.update(&mut cx, |pane, cx| { + let room_id = ActiveCall::global(cx) + .read(cx) + .room() + .map(|room| room.read(cx).id()); + ActiveCall::report_call_event_for_room( + "open channel notes", + room_id, + Some(channel_id), + &workspace.read(cx).app_state().client, + cx, + ); + pane.add_item(Box::new(channel_view), true, true, None, cx); + }); + anyhow::Ok(()) + }) + .detach(); + } + pub fn open( channel_id: ChannelId, pane: ViewHandle, diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index afd293422f59ce205438a88d927a7b09033d0342..8e4148bd9baa3f99537868f6dbfe755e402228f7 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -1,5 +1,6 @@ -use crate::ChatPanelSettings; +use crate::{channel_view::ChannelView, ChatPanelSettings}; use anyhow::Result; +use call::ActiveCall; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; use client::Client; use db::kvp::KEY_VALUE_STORE; @@ -80,8 +81,11 @@ impl ChatPanel { editor }); + let workspace_handle = workspace.weak_handle(); + let channel_select = cx.add_view(|cx| { let channel_store = channel_store.clone(); + let workspace = workspace_handle.clone(); Select::new(0, cx, { move |ix, item_type, is_hovered, cx| { Self::render_channel_name( @@ -89,7 +93,8 @@ impl ChatPanel { ix, item_type, is_hovered, - &theme::current(cx).chat_panel.channel_select, + &theme::current(cx).chat_panel, + workspace, cx, ) } @@ -334,7 +339,7 @@ impl ChatPanel { cx, |mouse_state, _| { let button_style = - theme.collab_panel.contact_button.style_for(mouse_state); + theme.chat_panel.icon_button.style_for(mouse_state); render_icon_button(button_style, "icons/x.svg") .aligned() .into_any() @@ -366,28 +371,62 @@ impl ChatPanel { ix: usize, item_type: ItemType, is_hovered: bool, - theme: &theme::ChannelSelect, - cx: &AppContext, + theme: &theme::ChatPanel, + workspace: WeakViewHandle, + cx: &mut ViewContext { + enum ChannelNotes {} + enum JoinCall {} + let channel = &channel_store.read(cx).channel_at_index(ix).unwrap().1; - let theme = match (item_type, is_hovered) { - (ItemType::Header, _) => &theme.header, - (ItemType::Selected, false) => &theme.active_item, - (ItemType::Selected, true) => &theme.hovered_active_item, - (ItemType::Unselected, false) => &theme.item, - (ItemType::Unselected, true) => &theme.hovered_item, + let channel_id = channel.id; + let style = &theme.channel_select; + let style = match (&item_type, is_hovered) { + (ItemType::Header, _) => &style.header, + (ItemType::Selected, _) => &style.active_item, + (ItemType::Unselected, false) => &style.item, + (ItemType::Unselected, true) => &style.hovered_item, }; - Flex::row() + let mut row = Flex::row() .with_child( - Label::new("#".to_string(), theme.hash.text.clone()) + Label::new("#".to_string(), style.hash.text.clone()) .contained() - .with_style(theme.hash.container), + .with_style(style.hash.container), ) - .with_child(Label::new(channel.name.clone(), theme.name.clone())) - .aligned() - .left() + .with_child(Label::new(channel.name.clone(), style.name.clone())); + + if matches!(item_type, ItemType::Header) { + row.add_children([ + MouseEventHandler::new::(0, cx, |mouse_state, _| { + render_icon_button( + theme.icon_button.style_for(mouse_state), + "icons/radix/file.svg", + ) + }) + .on_click(MouseButton::Left, move |_, _, cx| { + if let Some(workspace) = workspace.upgrade(cx) { + ChannelView::deploy(channel_id, workspace, cx); + } + }) + .flex_float(), + MouseEventHandler::new::(0, cx, |mouse_state, _| { + render_icon_button( + theme.icon_button.style_for(mouse_state), + "icons/radix/speaker-loud.svg", + ) + }) + .on_click(MouseButton::Left, move |_, _, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| call.join_channel(channel_id, cx)) + .detach_and_log_err(cx); + }) + .flex_float(), + ]); + } + + row.align_children_center() .contained() - .with_style(theme.container) + .with_style(style.container) .into_any() } @@ -511,6 +550,7 @@ impl View for ChatPanel { } fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + self.has_focus = true; if matches!( *self.client.status().borrow(), client::Status::Connected { .. } @@ -518,6 +558,10 @@ impl View for ChatPanel { cx.focus(&self.input_editor); } } + + fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + self.has_focus = false; + } } impl Panel for ChatPanel { @@ -594,7 +638,7 @@ fn format_timestamp( } } -fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { +fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { Svg::new(svg_path) .with_color(style.color) .constrained() diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index ad1c2a0037176615ca6911df5db6e5eec1a26ae2..437eaad3fed05ceff753d9f7880070c8cb5107d0 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2252,29 +2252,7 @@ impl CollabPanel { fn open_channel_notes(&mut self, action: &OpenChannelNotes, cx: &mut ViewContext) { if let Some(workspace) = self.workspace.upgrade(cx) { - let pane = workspace.read(cx).active_pane().clone(); - let channel_id = action.channel_id; - let channel_view = ChannelView::open(channel_id, pane.clone(), workspace, cx); - cx.spawn(|_, mut cx| async move { - let channel_view = channel_view.await?; - pane.update(&mut cx, |pane, cx| { - pane.add_item(Box::new(channel_view), true, true, None, cx) - }); - anyhow::Ok(()) - }) - .detach(); - let room_id = ActiveCall::global(cx) - .read(cx) - .room() - .map(|room| room.read(cx).id()); - - ActiveCall::report_call_event_for_room( - "open channel notes", - room_id, - Some(channel_id), - &self.client, - cx, - ); + ChannelView::deploy(action.channel_id, workspace, cx); } } @@ -2436,8 +2414,6 @@ impl CollabPanel { } fn join_channel_chat(&mut self, channel_id: u64, cx: &mut ViewContext) { - self.open_channel_notes(&OpenChannelNotes { channel_id }, cx); - if let Some(workspace) = self.workspace.upgrade(cx) { cx.app_context().defer(move |cx| { workspace.update(cx, |workspace, cx| { diff --git a/crates/gpui/src/views/select.rs b/crates/gpui/src/views/select.rs index 0d57630c244d2e112158c9c9468b76cbb4e5eaa9..bad65ccfc8e0d9bf583847ac12058d3e08ad2865 100644 --- a/crates/gpui/src/views/select.rs +++ b/crates/gpui/src/views/select.rs @@ -6,7 +6,7 @@ use crate::{ pub struct Select { handle: WeakViewHandle, - render_item: Box AnyElement>, + render_item: Box) -> AnyElement>, selected_item_ix: usize, item_count: usize, is_open: bool, @@ -29,7 +29,9 @@ pub enum ItemType { pub enum Event {} impl Select { - pub fn new AnyElement>( + pub fn new< + F: 'static + Fn(usize, ItemType, bool, &mut ViewContext) -> AnyElement, + >( item_count: usize, cx: &mut ViewContext, render_item: F, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 613c88d5cc8ae174a02531aa3210345107b965c7..5ea5ce877829e2c3dae5f622b08ec35e0c283e34 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -635,6 +635,7 @@ pub struct ChatPanel { pub message: ChatMessage, pub pending_message: ChatMessage, pub sign_in_prompt: Interactive, + pub icon_button: Interactive, } #[derive(Deserialize, Default, JsonSchema)] @@ -654,7 +655,6 @@ pub struct ChannelSelect { pub item: ChannelName, pub active_item: ChannelName, pub hovered_item: ChannelName, - pub hovered_active_item: ChannelName, pub menu: ContainerStyle, } diff --git a/styles/src/style_tree/chat_panel.ts b/styles/src/style_tree/chat_panel.ts index d0e5d3c2499a376a7df98cf7bf6c54b48c4a195b..9efa0844569103036c6b19de7a71f3708bdd8cc2 100644 --- a/styles/src/style_tree/chat_panel.ts +++ b/styles/src/style_tree/chat_panel.ts @@ -3,6 +3,7 @@ import { border, text, } from "./components" +import { icon_button } from "../component/icon_button" import { useTheme } from "../theme" export default function chat_panel(): any { @@ -46,15 +47,16 @@ export default function chat_panel(): any { ...channel_name, background: background(layer, "on", "hovered"), }, - hovered_active_item: { - ...channel_name, - background: background(layer, "on", "active"), - }, menu: { background: background(layer, "on"), border: border(layer, { bottom: true }) } }, + icon_button: icon_button({ + variant: "ghost", + color: "variant", + size: "sm", + }), input_editor: { background: background(layer, "on"), corner_radius: 6, From 6ce672fb325dd56eb19b487eb10fd42e075b7523 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 14 Sep 2023 18:38:37 -0700 Subject: [PATCH 15/19] Add tooltips and actions for opening notes+call from chat No keyboard shortcut yet. --- crates/collab_ui/src/chat_panel.rs | 66 ++++++++++++++++++++++------ crates/collab_ui/src/collab_panel.rs | 12 +++-- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 8e4148bd9baa3f99537868f6dbfe755e402228f7..be2dbf811cd34f4b441294e2c8446112e7c52d62 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -43,6 +43,7 @@ pub struct ChatPanel { width: Option, pending_serialization: Task>, subscriptions: Vec, + workspace: WeakViewHandle, has_focus: bool, } @@ -58,11 +59,16 @@ pub enum Event { Dismissed, } -actions!(chat_panel, [LoadMoreMessages, ToggleFocus]); +actions!( + chat_panel, + [LoadMoreMessages, ToggleFocus, OpenChannelNotes, JoinCall] +); pub fn init(cx: &mut AppContext) { cx.add_action(ChatPanel::send); cx.add_action(ChatPanel::load_more_messages); + cx.add_action(ChatPanel::open_notes); + cx.add_action(ChatPanel::join_call); } impl ChatPanel { @@ -93,7 +99,6 @@ impl ChatPanel { ix, item_type, is_hovered, - &theme::current(cx).chat_panel, workspace, cx, ) @@ -131,6 +136,7 @@ impl ChatPanel { local_timezone: cx.platform().local_timezone(), has_focus: false, subscriptions: Vec::new(), + workspace: workspace_handle, width: None, }; @@ -371,22 +377,22 @@ impl ChatPanel { ix: usize, item_type: ItemType, is_hovered: bool, - theme: &theme::ChatPanel, workspace: WeakViewHandle, cx: &mut ViewContext { - enum ChannelNotes {} - enum JoinCall {} + let theme = theme::current(cx); + let tooltip_style = &theme.tooltip; + let theme = &theme.chat_panel; + let style = match (&item_type, is_hovered) { + (ItemType::Header, _) => &theme.channel_select.header, + (ItemType::Selected, _) => &theme.channel_select.active_item, + (ItemType::Unselected, false) => &theme.channel_select.item, + (ItemType::Unselected, true) => &theme.channel_select.hovered_item, + }; let channel = &channel_store.read(cx).channel_at_index(ix).unwrap().1; let channel_id = channel.id; - let style = &theme.channel_select; - let style = match (&item_type, is_hovered) { - (ItemType::Header, _) => &style.header, - (ItemType::Selected, _) => &style.active_item, - (ItemType::Unselected, false) => &style.item, - (ItemType::Unselected, true) => &style.hovered_item, - }; + let mut row = Flex::row() .with_child( Label::new("#".to_string(), style.hash.text.clone()) @@ -397,7 +403,7 @@ impl ChatPanel { if matches!(item_type, ItemType::Header) { row.add_children([ - MouseEventHandler::new::(0, cx, |mouse_state, _| { + MouseEventHandler::new::(0, cx, |mouse_state, _| { render_icon_button( theme.icon_button.style_for(mouse_state), "icons/radix/file.svg", @@ -408,8 +414,15 @@ impl ChatPanel { ChannelView::deploy(channel_id, workspace, cx); } }) + .with_tooltip::( + channel_id as usize, + "Open Notes", + Some(Box::new(OpenChannelNotes)), + tooltip_style.clone(), + cx, + ) .flex_float(), - MouseEventHandler::new::(0, cx, |mouse_state, _| { + MouseEventHandler::new::(0, cx, |mouse_state, _| { render_icon_button( theme.icon_button.style_for(mouse_state), "icons/radix/speaker-loud.svg", @@ -420,6 +433,13 @@ impl ChatPanel { .update(cx, |call, cx| call.join_channel(channel_id, cx)) .detach_and_log_err(cx); }) + .with_tooltip::( + channel_id as usize, + "Join Call", + Some(Box::new(JoinCall)), + tooltip_style.clone(), + cx, + ) .flex_float(), ]); } @@ -523,6 +543,24 @@ impl ChatPanel { }) }) } + + fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext) { + if let Some((chat, _)) = &self.active_chat { + let channel_id = chat.read(cx).channel().id; + if let Some(workspace) = self.workspace.upgrade(cx) { + ChannelView::deploy(channel_id, workspace, cx); + } + } + } + + fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext) { + if let Some((chat, _)) = &self.active_chat { + let channel_id = chat.read(cx).channel().id; + ActiveCall::global(cx) + .update(cx, |call, cx| call.join_channel(channel_id, cx)) + .detach_and_log_err(cx); + } + } } impl Entity for ChatPanel { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 437eaad3fed05ceff753d9f7880070c8cb5107d0..d5189b5e4dcfaa316d8bbdb4b9521c84b42df1de 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -80,8 +80,13 @@ struct RenameChannel { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -struct OpenChannelNotes { - channel_id: u64, +pub struct OpenChannelNotes { + pub channel_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct JoinChannelCall { + pub channel_id: u64, } actions!( @@ -104,7 +109,8 @@ impl_actions!( ManageMembers, RenameChannel, ToggleCollapse, - OpenChannelNotes + OpenChannelNotes, + JoinChannelCall, ] ); From 492e961e820d090cdb99b012cd071598aafe8e78 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 14 Sep 2023 18:39:31 -0700 Subject: [PATCH 16/19] Bump protocol version --- crates/rpc/src/rpc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index d64cbae92993ec2b092fcebdcf48d20f2c7449d6..a1393f56e99a2d5bd83858d3a3adc1359ef91596 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -6,4 +6,4 @@ pub use conn::Connection; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 62; +pub const PROTOCOL_VERSION: u32 = 63; From 099969ed79f641854352ed52180c3aeae164fdd2 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 15 Sep 2023 10:12:28 -0700 Subject: [PATCH 17/19] Ensure the chat panel is fully feature flagged --- crates/collab_ui/src/chat_panel.rs | 18 ++++++++++++++++-- crates/zed/src/main.rs | 6 ------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index be2dbf811cd34f4b441294e2c8446112e7c52d62..c8f0f9bc8d39ed51d99614020046109c56713ca9 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -5,6 +5,7 @@ use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; use client::Client; use db::kvp::KEY_VALUE_STORE; use editor::Editor; +use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; use gpui::{ actions, elements::*, @@ -628,9 +629,14 @@ impl Panel for ChatPanel { cx.notify(); } + fn set_active(&mut self, active: bool, cx: &mut ViewContext) { + if active && !is_chat_feature_enabled(cx) { + cx.emit(Event::Dismissed); + } + } + fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { - settings::get::(cx) - .button + (settings::get::(cx).button && is_chat_feature_enabled(cx)) .then(|| "icons/conversations.svg") } @@ -642,6 +648,10 @@ impl Panel for ChatPanel { matches!(event, Event::DockPositionChanged) } + fn should_close_on_event(event: &Self::Event) -> bool { + matches!(event, Event::Dismissed) + } + fn has_focus(&self, _cx: &gpui::WindowContext) -> bool { self.has_focus } @@ -651,6 +661,10 @@ impl Panel for ChatPanel { } } +fn is_chat_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool { + cx.is_staff() || cx.has_flag::() +} + fn format_timestamp( mut timestamp: OffsetDateTime, mut now: OffsetDateTime, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f78a4f6419ea57240f0d1bab3a2e9f0e26e0b9dd..c800e4e11097b02101c730b6e47b2263294a38cd 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -119,12 +119,6 @@ fn main() { app.run(move |cx| { cx.set_global(*RELEASE_CHANNEL); - #[cfg(debug_assertions)] - { - use feature_flags::FeatureFlagAppExt; - cx.set_staff(true); - } - let mut store = SettingsStore::default(); store .set_default_settings(default_settings().as_ref(), cx) From 3dba52340efcfc38492745e8cc17e1e61773a957 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 15 Sep 2023 11:14:04 -0700 Subject: [PATCH 18/19] Update paths to moved icons --- crates/collab_ui/src/chat_panel.rs | 7 ++----- crates/collab_ui/src/collab_panel.rs | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index c8f0f9bc8d39ed51d99614020046109c56713ca9..087f2e1b8e5e9e2c95aebe9871ee1d86f9a95b8e 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -405,10 +405,7 @@ impl ChatPanel { if matches!(item_type, ItemType::Header) { row.add_children([ MouseEventHandler::new::(0, cx, |mouse_state, _| { - render_icon_button( - theme.icon_button.style_for(mouse_state), - "icons/radix/file.svg", - ) + render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg") }) .on_click(MouseButton::Left, move |_, _, cx| { if let Some(workspace) = workspace.upgrade(cx) { @@ -426,7 +423,7 @@ impl ChatPanel { MouseEventHandler::new::(0, cx, |mouse_state, _| { render_icon_button( theme.icon_button.style_for(mouse_state), - "icons/radix/speaker-loud.svg", + "icons/speaker-loud.svg", ) }) .on_click(MouseButton::Left, move |_, _, cx| { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 2af77ea2d3faabc4bf0388acad3b274fbc101ffb..a258151bb805387b4614ff5886f5875235ede2d0 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1621,7 +1621,7 @@ impl CollabPanel { })) .into_any() } else if row_hovered { - Svg::new("icons/radix/speaker-loud.svg") + Svg::new("icons/speaker-loud.svg") .with_color(theme.channel_hash.color) .constrained() .with_width(theme.channel_hash.width) From b8fd4f5d40a55df1c4a8e62cb43a7233947d89f0 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 15 Sep 2023 11:16:30 -0700 Subject: [PATCH 19/19] Restore user_group_16 icon --- assets/icons/user_group_16.svg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 assets/icons/user_group_16.svg diff --git a/assets/icons/user_group_16.svg b/assets/icons/user_group_16.svg new file mode 100644 index 0000000000000000000000000000000000000000..aa99277646653c899ee049547e5574b76b25b840 --- /dev/null +++ b/assets/icons/user_group_16.svg @@ -0,0 +1,3 @@ + + +