join channels (#3082)

Conrad Irwin created

Release Notes:

- Clicking on a channel in the sidebar will now join the channel and
open the notes
- If you join a channel that already shared projects, you will join the
projects automatically and follow the host.
- Clicking on the current channel in the sidebar will re-open the notes.
- Chat can now be accessed from the right click menu of channels.


- (probably not worth mentioning) Various improvements to hover states
and tooltips in the collab ui; and if you click on a channel while in
another call, confirm before switching.

Change summary

crates/call/src/call.rs              |   6 
crates/call/src/room.rs              |  27 ++
crates/collab_ui/src/collab_panel.rs | 372 ++++++++++++++++++++++-------
3 files changed, 306 insertions(+), 99 deletions(-)

Detailed changes

crates/call/src/call.rs 🔗

@@ -291,10 +291,10 @@ impl ActiveCall {
         &mut self,
         channel_id: u64,
         cx: &mut ModelContext<Self>,
-    ) -> Task<Result<()>> {
+    ) -> Task<Result<ModelHandle<Room>>> {
         if let Some(room) = self.room().cloned() {
             if room.read(cx).channel_id() == Some(channel_id) {
-                return Task::ready(Ok(()));
+                return Task::ready(Ok(room));
             } else {
                 room.update(cx, |room, cx| room.clear_state(cx));
             }
@@ -309,7 +309,7 @@ impl ActiveCall {
             this.update(&mut cx, |this, cx| {
                 this.report_call_event("join channel", cx)
             });
-            Ok(())
+            Ok(room)
         })
     }
 

crates/call/src/room.rs 🔗

@@ -594,6 +594,33 @@ impl Room {
             .map_or(&[], |v| v.as_slice())
     }
 
+    /// projects_to_join returns a list of shared projects sorted such
+    /// that the most 'active' projects appear last.
+    pub fn projects_to_join(&self) -> Vec<(u64, u64)> {
+        let mut projects = HashMap::default();
+        let mut hosts = HashMap::default();
+        for participant in self.remote_participants.values() {
+            match participant.location {
+                ParticipantLocation::SharedProject { project_id } => {
+                    *projects.entry(project_id).or_insert(0) += 1;
+                }
+                ParticipantLocation::External | ParticipantLocation::UnsharedProject => {}
+            }
+            for project in &participant.projects {
+                *projects.entry(project.id).or_insert(0) += 1;
+                hosts.insert(project.id, participant.user.id);
+            }
+        }
+
+        let mut pairs: Vec<(u64, usize)> = projects.into_iter().collect();
+        pairs.sort_by_key(|(_, count)| 0 - *count as i32);
+
+        pairs
+            .into_iter()
+            .map(|(project_id, _)| (project_id, hosts[&project_id]))
+            .collect()
+    }
+
     async fn handle_room_updated(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::RoomUpdated>,

crates/collab_ui/src/collab_panel.rs 🔗

@@ -95,6 +95,11 @@ pub struct JoinChannelCall {
     pub channel_id: u64,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct JoinChannelChat {
+    pub channel_id: u64,
+}
+
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 struct StartMoveChannelFor {
     channel_id: ChannelId,
@@ -151,6 +156,7 @@ impl_actions!(
         ToggleCollapse,
         OpenChannelNotes,
         JoinChannelCall,
+        JoinChannelChat,
         LinkChannel,
         StartMoveChannelFor,
         StartLinkChannelFor,
@@ -198,6 +204,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(CollabPanel::collapse_selected_channel);
     cx.add_action(CollabPanel::expand_selected_channel);
     cx.add_action(CollabPanel::open_channel_notes);
+    cx.add_action(CollabPanel::join_channel_chat);
 
     cx.add_action(
         |panel: &mut CollabPanel, action: &ToggleSelectedIx, cx: &mut ViewContext<CollabPanel>| {
@@ -471,6 +478,12 @@ impl CollabPanel {
                             .iter()
                             .position(|entry| !matches!(entry, ListEntry::Header(_)));
                     }
+                } else if let editor::Event::Blurred = event {
+                    let query = this.filter_editor.read(cx).text(cx);
+                    if query.is_empty() {
+                        this.selection.take();
+                        this.update_entries(true, cx);
+                    }
                 }
             })
             .detach();
@@ -555,7 +568,7 @@ impl CollabPanel {
                                 &*channel,
                                 *depth,
                                 path.to_owned(),
-                                &theme.collab_panel,
+                                &theme,
                                 is_selected,
                                 ix,
                                 cx,
@@ -608,7 +621,7 @@ impl CollabPanel {
                             contact,
                             *calling,
                             &this.project,
-                            &theme.collab_panel,
+                            &theme,
                             is_selected,
                             cx,
                         ),
@@ -768,9 +781,16 @@ impl CollabPanel {
 
         let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
         let old_entries = mem::take(&mut self.entries);
+        let mut scroll_to_top = false;
 
         if let Some(room) = ActiveCall::global(cx).read(cx).room() {
             self.entries.push(ListEntry::Header(Section::ActiveCall));
+            if !old_entries
+                .iter()
+                .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
+            {
+                scroll_to_top = true;
+            }
 
             if !self.collapsed_sections.contains(&Section::ActiveCall) {
                 let room = room.read(cx);
@@ -1138,44 +1158,49 @@ impl CollabPanel {
         }
 
         let old_scroll_top = self.list_state.logical_scroll_top();
+
         self.list_state.reset(self.entries.len());
 
-        // Attempt to maintain the same scroll position.
-        if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
-            let new_scroll_top = self
-                .entries
-                .iter()
-                .position(|entry| entry == old_top_entry)
-                .map(|item_ix| ListOffset {
-                    item_ix,
-                    offset_in_item: old_scroll_top.offset_in_item,
-                })
-                .or_else(|| {
-                    let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
-                    let item_ix = self
-                        .entries
-                        .iter()
-                        .position(|entry| entry == entry_after_old_top)?;
-                    Some(ListOffset {
+        if scroll_to_top {
+            self.list_state.scroll_to(ListOffset::default());
+        } else {
+            // Attempt to maintain the same scroll position.
+            if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
+                let new_scroll_top = self
+                    .entries
+                    .iter()
+                    .position(|entry| entry == old_top_entry)
+                    .map(|item_ix| ListOffset {
                         item_ix,
-                        offset_in_item: 0.,
+                        offset_in_item: old_scroll_top.offset_in_item,
                     })
-                })
-                .or_else(|| {
-                    let entry_before_old_top =
-                        old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
-                    let item_ix = self
-                        .entries
-                        .iter()
-                        .position(|entry| entry == entry_before_old_top)?;
-                    Some(ListOffset {
-                        item_ix,
-                        offset_in_item: 0.,
+                    .or_else(|| {
+                        let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
+                        let item_ix = self
+                            .entries
+                            .iter()
+                            .position(|entry| entry == entry_after_old_top)?;
+                        Some(ListOffset {
+                            item_ix,
+                            offset_in_item: 0.,
+                        })
                     })
-                });
+                    .or_else(|| {
+                        let entry_before_old_top =
+                            old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
+                        let item_ix = self
+                            .entries
+                            .iter()
+                            .position(|entry| entry == entry_before_old_top)?;
+                        Some(ListOffset {
+                            item_ix,
+                            offset_in_item: 0.,
+                        })
+                    });
 
-            self.list_state
-                .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
+                self.list_state
+                    .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
+            }
         }
 
         cx.notify();
@@ -1633,16 +1658,20 @@ impl CollabPanel {
         contact: &Contact,
         calling: bool,
         project: &ModelHandle<Project>,
-        theme: &theme::CollabPanel,
+        theme: &theme::Theme,
         is_selected: bool,
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
+        enum ContactTooltip {}
+
+        let collab_theme = &theme.collab_panel;
         let online = contact.online;
         let busy = contact.busy || calling;
         let user_id = contact.user.id;
         let github_login = contact.user.github_login.clone();
         let initial_project = project.clone();
-        let mut event_handler =
+
+        let event_handler =
             MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |state, cx| {
                 Flex::row()
                     .with_children(contact.user.avatar.clone().map(|avatar| {
@@ -1652,9 +1681,9 @@ impl CollabPanel {
                                     .collapsed()
                                     .contained()
                                     .with_style(if busy {
-                                        theme.contact_status_busy
+                                        collab_theme.contact_status_busy
                                     } else {
-                                        theme.contact_status_free
+                                        collab_theme.contact_status_free
                                     })
                                     .aligned(),
                             )
@@ -1664,7 +1693,7 @@ impl CollabPanel {
                         Stack::new()
                             .with_child(
                                 Image::from_data(avatar)
-                                    .with_style(theme.contact_avatar)
+                                    .with_style(collab_theme.contact_avatar)
                                     .aligned()
                                     .left(),
                             )
@@ -1673,58 +1702,94 @@ impl CollabPanel {
                     .with_child(
                         Label::new(
                             contact.user.github_login.clone(),
-                            theme.contact_username.text.clone(),
+                            collab_theme.contact_username.text.clone(),
                         )
                         .contained()
-                        .with_style(theme.contact_username.container)
+                        .with_style(collab_theme.contact_username.container)
                         .aligned()
                         .left()
                         .flex(1., true),
                     )
-                    .with_child(
-                        MouseEventHandler::new::<Cancel, _>(
-                            contact.user.id as usize,
-                            cx,
-                            |mouse_state, _| {
-                                let button_style = theme.contact_button.style_for(mouse_state);
-                                render_icon_button(button_style, "icons/x.svg")
-                                    .aligned()
-                                    .flex_float()
-                            },
+                    .with_children(if state.hovered() {
+                        Some(
+                            MouseEventHandler::new::<Cancel, _>(
+                                contact.user.id as usize,
+                                cx,
+                                |mouse_state, _| {
+                                    let button_style =
+                                        collab_theme.contact_button.style_for(mouse_state);
+                                    render_icon_button(button_style, "icons/x.svg")
+                                        .aligned()
+                                        .flex_float()
+                                },
+                            )
+                            .with_padding(Padding::uniform(2.))
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(MouseButton::Left, move |_, this, cx| {
+                                this.remove_contact(user_id, &github_login, cx);
+                            })
+                            .flex_float(),
                         )
-                        .with_padding(Padding::uniform(2.))
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(MouseButton::Left, move |_, this, cx| {
-                            this.remove_contact(user_id, &github_login, cx);
-                        })
-                        .flex_float(),
-                    )
+                    } else {
+                        None
+                    })
                     .with_children(if calling {
                         Some(
-                            Label::new("Calling", theme.calling_indicator.text.clone())
+                            Label::new("Calling", collab_theme.calling_indicator.text.clone())
                                 .contained()
-                                .with_style(theme.calling_indicator.container)
+                                .with_style(collab_theme.calling_indicator.container)
                                 .aligned(),
                         )
                     } else {
                         None
                     })
                     .constrained()
-                    .with_height(theme.row_height)
+                    .with_height(collab_theme.row_height)
                     .contained()
-                    .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
-            })
-            .on_click(MouseButton::Left, move |_, this, cx| {
-                if online && !busy {
-                    this.call(user_id, Some(initial_project.clone()), cx);
-                }
+                    .with_style(
+                        *collab_theme
+                            .contact_row
+                            .in_state(is_selected)
+                            .style_for(state),
+                    )
             });
 
-        if online {
-            event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
-        }
+        if online && !busy {
+            let room = ActiveCall::global(cx).read(cx).room();
+            let label = if room.is_some() {
+                format!("Invite {} to join call", contact.user.github_login)
+            } else {
+                format!("Call {}", contact.user.github_login)
+            };
 
-        event_handler.into_any()
+            event_handler
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.call(user_id, Some(initial_project.clone()), cx);
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .with_tooltip::<ContactTooltip>(
+                    contact.user.id as usize,
+                    label,
+                    None,
+                    theme.tooltip.clone(),
+                    cx,
+                )
+                .into_any()
+        } else {
+            event_handler
+                .with_tooltip::<ContactTooltip>(
+                    contact.user.id as usize,
+                    format!(
+                        "{} is {}",
+                        contact.user.github_login,
+                        if busy { "on a call" } else { "offline" }
+                    ),
+                    None,
+                    theme.tooltip.clone(),
+                    cx,
+                )
+                .into_any()
+        }
     }
 
     fn render_contact_placeholder(
@@ -1827,12 +1892,13 @@ impl CollabPanel {
         channel: &Channel,
         depth: usize,
         path: ChannelPath,
-        theme: &theme::CollabPanel,
+        theme: &theme::Theme,
         is_selected: bool,
         ix: usize,
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
         let channel_id = channel.id;
+        let collab_theme = &theme.collab_panel;
         let has_children = self.channel_store.read(cx).has_children(channel_id);
         let other_selected =
             self.selected_channel().map(|channel| channel.0.id) == Some(channel.id);
@@ -1851,6 +1917,8 @@ impl CollabPanel {
         const FACEPILE_LIMIT: usize = 3;
 
         enum ChannelCall {}
+        enum IconTooltip {}
+        enum ChannelTooltip {}
 
         let mut is_dragged_over = false;
         if cx
@@ -1886,18 +1954,29 @@ impl CollabPanel {
             Flex::<Self>::row()
                 .with_child(
                     Svg::new("icons/hash.svg")
-                        .with_color(theme.channel_hash.color)
+                        .with_color(collab_theme.channel_hash.color)
                         .constrained()
-                        .with_width(theme.channel_hash.width)
+                        .with_width(collab_theme.channel_hash.width)
                         .aligned()
                         .left(),
                 )
                 .with_child(
-                    Label::new(channel.name.clone(), theme.channel_name.text.clone())
+                    Label::new(channel.name.clone(), collab_theme.channel_name.text.clone())
                         .contained()
-                        .with_style(theme.channel_name.container)
+                        .with_style(collab_theme.channel_name.container)
                         .aligned()
                         .left()
+                        .with_tooltip::<ChannelTooltip>(
+                            channel_id as usize,
+                            if is_active {
+                                "Open channel notes"
+                            } else {
+                                "Join channel"
+                            },
+                            None,
+                            theme.tooltip.clone(),
+                            cx,
+                        )
                         .flex(1., true),
                 )
                 .with_child(
@@ -1907,14 +1986,14 @@ impl CollabPanel {
                         if !participants.is_empty() {
                             let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
 
-                            FacePile::new(theme.face_overlap)
+                            FacePile::new(collab_theme.face_overlap)
                                 .with_children(
                                     participants
                                         .iter()
                                         .filter_map(|user| {
                                             Some(
                                                 Image::from_data(user.avatar.clone()?)
-                                                    .with_style(theme.channel_avatar),
+                                                    .with_style(collab_theme.channel_avatar),
                                             )
                                         })
                                         .take(FACEPILE_LIMIT),
@@ -1922,24 +2001,48 @@ impl CollabPanel {
                                 .with_children((extra_count > 0).then(|| {
                                     Label::new(
                                         format!("+{}", extra_count),
-                                        theme.extra_participant_label.text.clone(),
+                                        collab_theme.extra_participant_label.text.clone(),
                                     )
                                     .contained()
-                                    .with_style(theme.extra_participant_label.container)
+                                    .with_style(collab_theme.extra_participant_label.container)
                                 }))
+                                .with_tooltip::<IconTooltip>(
+                                    channel_id as usize,
+                                    if is_active {
+                                        "Open Channel Notes"
+                                    } else {
+                                        "Join channel"
+                                    },
+                                    None,
+                                    theme.tooltip.clone(),
+                                    cx,
+                                )
                                 .into_any()
                         } else if row_hovered {
-                            Svg::new("icons/speaker-loud.svg")
-                                .with_color(theme.channel_hash.color)
+                            Svg::new("icons/file.svg")
+                                .with_color(collab_theme.channel_hash.color)
                                 .constrained()
-                                .with_width(theme.channel_hash.width)
+                                .with_width(collab_theme.channel_hash.width)
+                                .with_tooltip::<IconTooltip>(
+                                    channel_id as usize,
+                                    "Open channel notes",
+                                    None,
+                                    theme.tooltip.clone(),
+                                    cx,
+                                )
                                 .into_any()
                         } else {
                             Empty::new().into_any()
                         }
                     })
                     .on_click(MouseButton::Left, move |_, this, cx| {
-                        this.join_channel_call(channel_id, cx);
+                        let participants =
+                            this.channel_store.read(cx).channel_participants(channel_id);
+                        if is_active || participants.is_empty() {
+                            this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
+                        } else {
+                            this.join_channel(channel_id, cx);
+                        };
                     }),
                 )
                 .align_children_center()
@@ -1951,24 +2054,28 @@ impl CollabPanel {
                     }),
                 )
                 .with_id(ix)
-                .with_style(theme.disclosure.clone())
+                .with_style(collab_theme.disclosure.clone())
                 .element()
                 .constrained()
-                .with_height(theme.row_height)
+                .with_height(collab_theme.row_height)
                 .contained()
                 .with_style(select_state(
-                    theme
+                    collab_theme
                         .channel_row
                         .in_state(is_selected || is_active || is_dragged_over),
                 ))
                 .with_padding_left(
-                    theme.channel_row.default_style().padding.left
-                        + theme.channel_indent * depth as f32,
+                    collab_theme.channel_row.default_style().padding.left
+                        + collab_theme.channel_indent * depth as f32,
                 )
         })
         .on_click(MouseButton::Left, move |_, this, cx| {
             if this.drag_target_channel.take().is_none() {
-                this.join_channel_chat(channel_id, cx);
+                if is_active {
+                    this.open_channel_notes(&OpenChannelNotes { channel_id }, cx)
+                } else {
+                    this.join_channel(channel_id, cx)
+                }
             }
         })
         .on_click(MouseButton::Right, {
@@ -2392,6 +2499,13 @@ impl CollabPanel {
                 },
             ));
 
+            items.push(ContextMenuItem::action(
+                "Open Chat",
+                JoinChannelChat {
+                    channel_id: path.channel_id(),
+                },
+            ));
+
             if self.channel_store.read(cx).is_user_admin(path.channel_id()) {
                 let parent_id = path.parent_id();
 
@@ -2588,7 +2702,28 @@ impl CollabPanel {
                         }
                     }
                     ListEntry::Channel { channel, .. } => {
-                        self.join_channel_chat(channel.id, cx);
+                        let is_active = iife!({
+                            let call_channel = ActiveCall::global(cx)
+                                .read(cx)
+                                .room()?
+                                .read(cx)
+                                .channel_id()?;
+
+                            dbg!(call_channel, channel.id);
+                            Some(call_channel == channel.id)
+                        })
+                        .unwrap_or(false);
+                        dbg!(is_active);
+                        if is_active {
+                            self.open_channel_notes(
+                                &OpenChannelNotes {
+                                    channel_id: channel.id,
+                                },
+                                cx,
+                            )
+                        } else {
+                            self.join_channel(channel.id, cx)
+                        }
                     }
                     ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
                     _ => {}
@@ -2991,13 +3126,58 @@ impl CollabPanel {
             .detach_and_log_err(cx);
     }
 
-    fn join_channel_call(&self, channel: u64, cx: &mut ViewContext<Self>) {
-        ActiveCall::global(cx)
-            .update(cx, |call, cx| call.join_channel(channel, cx))
-            .detach_and_log_err(cx);
+    fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
+        let workspace = self.workspace.clone();
+        let window = cx.window();
+        let active_call = ActiveCall::global(cx);
+        cx.spawn(|_, mut cx| async move {
+            if active_call.read_with(&mut cx, |active_call, _| active_call.room().is_some()) {
+                let answer = window.prompt(
+                    PromptLevel::Warning,
+                    "Do you want to leave the current call?",
+                    &["Yes, Join Channel", "Cancel"],
+                    &mut cx,
+                );
+
+                if let Some(mut answer) = answer {
+                    if answer.next().await == Some(1) {
+                        return anyhow::Ok(());
+                    }
+                }
+            }
+
+            let room = active_call
+                .update(&mut cx, |call, cx| call.join_channel(channel_id, cx))
+                .await?;
+
+            let tasks = room.update(&mut cx, |room, cx| {
+                let Some(workspace) = workspace.upgrade(cx) else {
+                    return vec![];
+                };
+                let projects = room.projects_to_join();
+
+                if projects.is_empty() {
+                    ChannelView::open(channel_id, workspace, cx).detach();
+                    return vec![];
+                }
+                room.projects_to_join()
+                    .into_iter()
+                    .map(|(project_id, user_id)| {
+                        let app_state = workspace.read(cx).app_state().clone();
+                        workspace::join_remote_project(project_id, user_id, app_state, cx)
+                    })
+                    .collect()
+            });
+            for task in tasks {
+                task.await?;
+            }
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
     }
 
-    fn join_channel_chat(&mut self, channel_id: u64, cx: &mut ViewContext<Self>) {
+    fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext<Self>) {
+        let channel_id = action.channel_id;
         if let Some(workspace) = self.workspace.upgrade(cx) {
             cx.app_context().defer(move |cx| {
                 workspace.update(cx, |workspace, cx| {