diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index ca0d06beb6..d86ed1be37 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -291,10 +291,10 @@ impl ActiveCall { &mut self, channel_id: u64, cx: &mut ModelContext, - ) -> Task> { + ) -> Task>> { 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) }) } diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 130a7a64f0..f24a8e9a9c 100644 --- a/crates/call/src/room.rs +++ b/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, envelope: TypedEnvelope, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 22ab573974..ab6261c568 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/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| { @@ -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, - theme: &theme::CollabPanel, + theme: &theme::Theme, is_selected: bool, cx: &mut ViewContext, ) -> AnyElement { + 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.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::( - 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::( + 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::( + contact.user.id as usize, + label, + None, + theme.tooltip.clone(), + cx, + ) + .into_any() + } else { + event_handler + .with_tooltip::( + 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, ) -> AnyElement { 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::::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::( + 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::( + 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::( + 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) { - 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) { + 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) { + fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext) { + 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| {