Move contact finder into contacts popover

Antonio Scandurra created

Change summary

crates/collab_ui/src/collab_ui.rs                  |   2 
crates/collab_ui/src/contact_finder.rs             |  34 
crates/collab_ui/src/contacts_popover.rs           | 966 ---------------
crates/collab_ui/src/incoming_call_notification.rs |  22 
crates/picker/src/picker.rs                        |  25 
crates/theme/src/theme.rs                          |  33 
styles/src/styleTree/app.ts                        |   4 
styles/src/styleTree/contactFinder.ts              |  25 
styles/src/styleTree/contactsPopover.ts            |   1 
9 files changed, 137 insertions(+), 975 deletions(-)

Detailed changes

crates/collab_ui/src/collab_ui.rs 🔗

@@ -1,6 +1,7 @@
 mod active_call_popover;
 mod collab_titlebar_item;
 mod contact_finder;
+mod contact_list;
 mod contact_notification;
 mod contacts_popover;
 mod incoming_call_notification;
@@ -18,6 +19,7 @@ use workspace::{AppState, JoinProject, ToggleFollow, Workspace};
 pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     collab_titlebar_item::init(cx);
     contact_notification::init(cx);
+    contact_list::init(cx);
     contact_finder::init(cx);
     contacts_popover::init(cx);
     incoming_call_notification::init(cx);

crates/collab_ui/src/contact_finder.rs 🔗

@@ -1,19 +1,15 @@
 use client::{ContactRequestStatus, User, UserStore};
 use gpui::{
-    actions, elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext,
-    RenderContext, Task, View, ViewContext, ViewHandle,
+    elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext,
+    Task, View, ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
 use settings::Settings;
 use std::sync::Arc;
 use util::TryFutureExt;
-use workspace::Workspace;
-
-actions!(contact_finder, [Toggle]);
 
 pub fn init(cx: &mut MutableAppContext) {
     Picker::<ContactFinder>::init(cx);
-    cx.add_action(ContactFinder::toggle);
 }
 
 pub struct ContactFinder {
@@ -166,34 +162,16 @@ impl PickerDelegate for ContactFinder {
 }
 
 impl ContactFinder {
-    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
-        workspace.toggle_modal(cx, |workspace, cx| {
-            let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
-            cx.subscribe(&finder, Self::on_event).detach();
-            finder
-        });
-    }
-
     pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
         let this = cx.weak_handle();
         Self {
-            picker: cx.add_view(|cx| Picker::new(this, cx)),
+            picker: cx.add_view(|cx| {
+                Picker::new(this, cx)
+                    .with_theme(|cx| &cx.global::<Settings>().theme.contact_finder.picker)
+            }),
             potential_contacts: Arc::from([]),
             user_store,
             selected_index: 0,
         }
     }
-
-    fn on_event(
-        workspace: &mut Workspace,
-        _: ViewHandle<Self>,
-        event: &Event,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        match event {
-            Event::Dismissed => {
-                workspace.dismiss_modal(cx);
-            }
-        }
-    }
 }

crates/collab_ui/src/contacts_popover.rs 🔗

@@ -1,120 +1,32 @@
-use std::sync::Arc;
-
-use crate::contact_finder;
-use call::ActiveCall;
-use client::{Contact, PeerId, User, UserStore};
-use editor::{Cancel, Editor};
-use fuzzy::{match_strings, StringMatchCandidate};
+use crate::{contact_finder::ContactFinder, contact_list::ContactList};
+use client::UserStore;
 use gpui::{
-    elements::*, impl_actions, impl_internal_actions, keymap, AppContext, ClipboardItem,
-    CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription,
-    View, ViewContext, ViewHandle,
+    actions, elements::*, Entity, ModelHandle, MutableAppContext, RenderContext, View, ViewContext,
+    ViewHandle,
 };
-use menu::{Confirm, SelectNext, SelectPrev};
 use project::Project;
-use serde::Deserialize;
 use settings::Settings;
-use theme::IconButton;
 
-impl_actions!(contacts_popover, [RemoveContact, RespondToContactRequest]);
-impl_internal_actions!(contacts_popover, [ToggleExpanded, Call]);
+actions!(contacts_popover, [ToggleContactFinder]);
 
 pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(ContactsPopover::remove_contact);
-    cx.add_action(ContactsPopover::respond_to_contact_request);
-    cx.add_action(ContactsPopover::clear_filter);
-    cx.add_action(ContactsPopover::select_next);
-    cx.add_action(ContactsPopover::select_prev);
-    cx.add_action(ContactsPopover::confirm);
-    cx.add_action(ContactsPopover::toggle_expanded);
-    cx.add_action(ContactsPopover::call);
-}
-
-#[derive(Clone, PartialEq)]
-struct ToggleExpanded(Section);
-
-#[derive(Clone, PartialEq)]
-struct Call {
-    recipient_user_id: u64,
-    initial_project: Option<ModelHandle<Project>>,
-}
-
-#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
-enum Section {
-    ActiveCall,
-    Requests,
-    Online,
-    Offline,
-}
-
-#[derive(Clone)]
-enum ContactEntry {
-    Header(Section),
-    CallParticipant { user: Arc<User>, is_pending: bool },
-    IncomingRequest(Arc<User>),
-    OutgoingRequest(Arc<User>),
-    Contact(Arc<Contact>),
-}
-
-impl PartialEq for ContactEntry {
-    fn eq(&self, other: &Self) -> bool {
-        match self {
-            ContactEntry::Header(section_1) => {
-                if let ContactEntry::Header(section_2) = other {
-                    return section_1 == section_2;
-                }
-            }
-            ContactEntry::CallParticipant { user: user_1, .. } => {
-                if let ContactEntry::CallParticipant { user: user_2, .. } = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::IncomingRequest(user_1) => {
-                if let ContactEntry::IncomingRequest(user_2) = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::OutgoingRequest(user_1) => {
-                if let ContactEntry::OutgoingRequest(user_2) = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::Contact(contact_1) => {
-                if let ContactEntry::Contact(contact_2) = other {
-                    return contact_1.user.id == contact_2.user.id;
-                }
-            }
-        }
-        false
-    }
-}
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RequestContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RemoveContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RespondToContactRequest {
-    pub user_id: u64,
-    pub accept: bool,
+    cx.add_action(ContactsPopover::toggle_contact_finder);
 }
 
 pub enum Event {
     Dismissed,
 }
 
+enum Child {
+    ContactList(ViewHandle<ContactList>),
+    ContactFinder(ViewHandle<ContactFinder>),
+}
+
 pub struct ContactsPopover {
-    entries: Vec<ContactEntry>,
-    match_candidates: Vec<StringMatchCandidate>,
-    list_state: ListState,
+    child: Child,
     project: ModelHandle<Project>,
     user_store: ModelHandle<UserStore>,
-    filter_editor: ViewHandle<Editor>,
-    collapsed_sections: Vec<Section>,
-    selection: Option<usize>,
-    _subscriptions: Vec<Subscription>,
+    _subscription: Option<gpui::Subscription>,
 }
 
 impl ContactsPopover {
@@ -123,729 +35,44 @@ impl ContactsPopover {
         user_store: ModelHandle<UserStore>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let filter_editor = cx.add_view(|cx| {
-            let mut editor = Editor::single_line(
-                Some(|theme| theme.contacts_popover.user_query_editor.clone()),
-                cx,
-            );
-            editor.set_placeholder_text("Filter contacts", cx);
-            editor
-        });
-
-        cx.subscribe(&filter_editor, |this, _, event, cx| {
-            if let editor::Event::BufferEdited = event {
-                let query = this.filter_editor.read(cx).text(cx);
-                if !query.is_empty() {
-                    this.selection.take();
-                }
-                this.update_entries(cx);
-                if !query.is_empty() {
-                    this.selection = this
-                        .entries
-                        .iter()
-                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
-                }
-            }
-        })
-        .detach();
-
-        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
-            let theme = cx.global::<Settings>().theme.clone();
-            let is_selected = this.selection == Some(ix);
-
-            match &this.entries[ix] {
-                ContactEntry::Header(section) => {
-                    let is_collapsed = this.collapsed_sections.contains(section);
-                    Self::render_header(
-                        *section,
-                        &theme.contacts_popover,
-                        is_selected,
-                        is_collapsed,
-                        cx,
-                    )
-                }
-                ContactEntry::CallParticipant { user, is_pending } => {
-                    Self::render_call_participant(
-                        user,
-                        *is_pending,
-                        is_selected,
-                        &theme.contacts_popover,
-                    )
-                }
-                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
-                    user.clone(),
-                    this.user_store.clone(),
-                    &theme.contacts_popover,
-                    true,
-                    is_selected,
-                    cx,
-                ),
-                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
-                    user.clone(),
-                    this.user_store.clone(),
-                    &theme.contacts_popover,
-                    false,
-                    is_selected,
-                    cx,
-                ),
-                ContactEntry::Contact(contact) => Self::render_contact(
-                    contact,
-                    &this.project,
-                    &theme.contacts_popover,
-                    is_selected,
-                    cx,
-                ),
-            }
-        });
-
-        let active_call = ActiveCall::global(cx);
-        let mut subscriptions = Vec::new();
-        subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
-        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
-
         let mut this = Self {
-            list_state,
-            selection: None,
-            collapsed_sections: Default::default(),
-            entries: Default::default(),
-            match_candidates: Default::default(),
-            filter_editor,
-            _subscriptions: subscriptions,
+            child: Child::ContactList(
+                cx.add_view(|cx| ContactList::new(project.clone(), user_store.clone(), cx)),
+            ),
             project,
             user_store,
+            _subscription: None,
         };
-        this.update_entries(cx);
+        this.show_contact_list(cx);
         this
     }
 
-    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
-        self.user_store
-            .update(cx, |store, cx| store.remove_contact(request.0, cx))
-            .detach();
-    }
-
-    fn respond_to_contact_request(
-        &mut self,
-        action: &RespondToContactRequest,
-        cx: &mut ViewContext<Self>,
-    ) {
-        self.user_store
-            .update(cx, |store, cx| {
-                store.respond_to_contact_request(action.user_id, action.accept, cx)
-            })
-            .detach();
-    }
-
-    fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
-        let did_clear = self.filter_editor.update(cx, |editor, cx| {
-            if editor.buffer().read(cx).len(cx) > 0 {
-                editor.set_text("", cx);
-                true
-            } else {
-                false
-            }
-        });
-        if !did_clear {
-            cx.emit(Event::Dismissed);
+    fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
+        match &self.child {
+            Child::ContactList(_) => self.show_contact_finder(cx),
+            Child::ContactFinder(_) => self.show_contact_list(cx),
         }
     }
 
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.selection {
-            if self.entries.len() > ix + 1 {
-                self.selection = Some(ix + 1);
-            }
-        } else if !self.entries.is_empty() {
-            self.selection = Some(0);
-        }
+    fn show_contact_finder(&mut self, cx: &mut ViewContext<ContactsPopover>) {
+        let child = cx.add_view(|cx| ContactFinder::new(self.user_store.clone(), cx));
+        cx.focus(&child);
+        self._subscription = Some(cx.subscribe(&child, |this, _, event, cx| match event {
+            crate::contact_finder::Event::Dismissed => this.show_contact_list(cx),
+        }));
+        self.child = Child::ContactFinder(child);
         cx.notify();
-        self.list_state.reset(self.entries.len());
     }
 
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.selection {
-            if ix > 0 {
-                self.selection = Some(ix - 1);
-            } else {
-                self.selection = None;
-            }
-        }
+    fn show_contact_list(&mut self, cx: &mut ViewContext<ContactsPopover>) {
+        let child =
+            cx.add_view(|cx| ContactList::new(self.project.clone(), self.user_store.clone(), cx));
+        cx.focus(&child);
+        self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
+            crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
+        }));
+        self.child = Child::ContactList(child);
         cx.notify();
-        self.list_state.reset(self.entries.len());
-    }
-
-    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
-        if let Some(selection) = self.selection {
-            if let Some(entry) = self.entries.get(selection) {
-                match entry {
-                    ContactEntry::Header(section) => {
-                        let section = *section;
-                        self.toggle_expanded(&ToggleExpanded(section), cx);
-                    }
-                    ContactEntry::Contact(contact) => {
-                        if contact.online && !contact.busy {
-                            self.call(
-                                &Call {
-                                    recipient_user_id: contact.user.id,
-                                    initial_project: Some(self.project.clone()),
-                                },
-                                cx,
-                            );
-                        }
-                    }
-                    _ => {}
-                }
-            }
-        }
-    }
-
-    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
-        let section = action.0;
-        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
-            self.collapsed_sections.remove(ix);
-        } else {
-            self.collapsed_sections.push(section);
-        }
-        self.update_entries(cx);
-    }
-
-    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
-        let user_store = self.user_store.read(cx);
-        let query = self.filter_editor.read(cx).text(cx);
-        let executor = cx.background().clone();
-
-        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
-        self.entries.clear();
-
-        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
-            let room = room.read(cx);
-            let mut call_participants = Vec::new();
-
-            // Populate the active user.
-            if let Some(user) = user_store.current_user() {
-                self.match_candidates.clear();
-                self.match_candidates.push(StringMatchCandidate {
-                    id: 0,
-                    string: user.github_login.clone(),
-                    char_bag: user.github_login.chars().collect(),
-                });
-                let matches = executor.block(match_strings(
-                    &self.match_candidates,
-                    &query,
-                    true,
-                    usize::MAX,
-                    &Default::default(),
-                    executor.clone(),
-                ));
-                if !matches.is_empty() {
-                    call_participants.push(ContactEntry::CallParticipant {
-                        user,
-                        is_pending: false,
-                    });
-                }
-            }
-
-            // Populate remote participants.
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    room.remote_participants()
-                        .iter()
-                        .map(|(peer_id, participant)| StringMatchCandidate {
-                            id: peer_id.0 as usize,
-                            string: participant.user.github_login.clone(),
-                            char_bag: participant.user.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            call_participants.extend(matches.iter().map(|mat| {
-                ContactEntry::CallParticipant {
-                    user: room.remote_participants()[&PeerId(mat.candidate_id as u32)]
-                        .user
-                        .clone(),
-                    is_pending: false,
-                }
-            }));
-
-            // Populate pending participants.
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    room.pending_participants()
-                        .iter()
-                        .enumerate()
-                        .map(|(id, participant)| StringMatchCandidate {
-                            id,
-                            string: participant.github_login.clone(),
-                            char_bag: participant.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            call_participants.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
-                user: room.pending_participants()[mat.candidate_id].clone(),
-                is_pending: true,
-            }));
-
-            if !call_participants.is_empty() {
-                self.entries.push(ContactEntry::Header(Section::ActiveCall));
-                if !self.collapsed_sections.contains(&Section::ActiveCall) {
-                    self.entries.extend(call_participants);
-                }
-            }
-        }
-
-        let mut request_entries = Vec::new();
-        let incoming = user_store.incoming_contact_requests();
-        if !incoming.is_empty() {
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    incoming
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, user)| StringMatchCandidate {
-                            id: ix,
-                            string: user.github_login.clone(),
-                            char_bag: user.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            request_entries.extend(
-                matches
-                    .iter()
-                    .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
-            );
-        }
-
-        let outgoing = user_store.outgoing_contact_requests();
-        if !outgoing.is_empty() {
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    outgoing
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, user)| StringMatchCandidate {
-                            id: ix,
-                            string: user.github_login.clone(),
-                            char_bag: user.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            request_entries.extend(
-                matches
-                    .iter()
-                    .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
-            );
-        }
-
-        if !request_entries.is_empty() {
-            self.entries.push(ContactEntry::Header(Section::Requests));
-            if !self.collapsed_sections.contains(&Section::Requests) {
-                self.entries.append(&mut request_entries);
-            }
-        }
-
-        let contacts = user_store.contacts();
-        if !contacts.is_empty() {
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    contacts
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, contact)| StringMatchCandidate {
-                            id: ix,
-                            string: contact.user.github_login.clone(),
-                            char_bag: contact.user.github_login.chars().collect(),
-                        }),
-                );
-
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-
-            let (mut online_contacts, offline_contacts) = matches
-                .iter()
-                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
-            if let Some(room) = ActiveCall::global(cx).read(cx).room() {
-                let room = room.read(cx);
-                online_contacts.retain(|contact| {
-                    let contact = &contacts[contact.candidate_id];
-                    !room.contains_participant(contact.user.id)
-                });
-            }
-
-            for (matches, section) in [
-                (online_contacts, Section::Online),
-                (offline_contacts, Section::Offline),
-            ] {
-                if !matches.is_empty() {
-                    self.entries.push(ContactEntry::Header(section));
-                    if !self.collapsed_sections.contains(&section) {
-                        for mat in matches {
-                            let contact = &contacts[mat.candidate_id];
-                            self.entries.push(ContactEntry::Contact(contact.clone()));
-                        }
-                    }
-                }
-            }
-        }
-
-        if let Some(prev_selected_entry) = prev_selected_entry {
-            self.selection.take();
-            for (ix, entry) in self.entries.iter().enumerate() {
-                if *entry == prev_selected_entry {
-                    self.selection = Some(ix);
-                    break;
-                }
-            }
-        }
-
-        self.list_state.reset(self.entries.len());
-        cx.notify();
-    }
-
-    fn render_call_participant(
-        user: &User,
-        is_pending: bool,
-        is_selected: bool,
-        theme: &theme::ContactsPopover,
-    ) -> ElementBox {
-        Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-                    .boxed()
-            }))
-            .with_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.contact_username.text.clone(),
-                )
-                .contained()
-                .with_style(theme.contact_username.container)
-                .aligned()
-                .left()
-                .flex(1., true)
-                .boxed(),
-            )
-            .with_children(if is_pending {
-                Some(
-                    Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
-                        .contained()
-                        .with_style(theme.calling_indicator.container)
-                        .aligned()
-                        .boxed(),
-                )
-            } else {
-                None
-            })
-            .constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
-            .boxed()
-    }
-
-    fn render_header(
-        section: Section,
-        theme: &theme::ContactsPopover,
-        is_selected: bool,
-        is_collapsed: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        enum Header {}
-
-        let header_style = theme.header_row.style_for(Default::default(), is_selected);
-        let text = match section {
-            Section::ActiveCall => "Call",
-            Section::Requests => "Requests",
-            Section::Online => "Online",
-            Section::Offline => "Offline",
-        };
-        let icon_size = theme.section_icon_size;
-        MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
-            Flex::row()
-                .with_child(
-                    Svg::new(if is_collapsed {
-                        "icons/chevron_right_8.svg"
-                    } else {
-                        "icons/chevron_down_8.svg"
-                    })
-                    .with_color(header_style.text.color)
-                    .constrained()
-                    .with_max_width(icon_size)
-                    .with_max_height(icon_size)
-                    .aligned()
-                    .constrained()
-                    .with_width(icon_size)
-                    .boxed(),
-                )
-                .with_child(
-                    Label::new(text.to_string(), header_style.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_margin_left(theme.contact_username.container.margin.left)
-                        .flex(1., true)
-                        .boxed(),
-                )
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(header_style.container)
-                .boxed()
-        })
-        .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(MouseButton::Left, move |_, cx| {
-            cx.dispatch_action(ToggleExpanded(section))
-        })
-        .boxed()
-    }
-
-    fn render_contact(
-        contact: &Contact,
-        project: &ModelHandle<Project>,
-        theme: &theme::ContactsPopover,
-        is_selected: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        let online = contact.online;
-        let busy = contact.busy;
-        let user_id = contact.user.id;
-        let initial_project = project.clone();
-        let mut element =
-            MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
-                Flex::row()
-                    .with_children(contact.user.avatar.clone().map(|avatar| {
-                        let status_badge = if contact.online {
-                            Some(
-                                Empty::new()
-                                    .collapsed()
-                                    .contained()
-                                    .with_style(if contact.busy {
-                                        theme.contact_status_busy
-                                    } else {
-                                        theme.contact_status_free
-                                    })
-                                    .aligned()
-                                    .boxed(),
-                            )
-                        } else {
-                            None
-                        };
-                        Stack::new()
-                            .with_child(
-                                Image::new(avatar)
-                                    .with_style(theme.contact_avatar)
-                                    .aligned()
-                                    .left()
-                                    .boxed(),
-                            )
-                            .with_children(status_badge)
-                            .boxed()
-                    }))
-                    .with_child(
-                        Label::new(
-                            contact.user.github_login.clone(),
-                            theme.contact_username.text.clone(),
-                        )
-                        .contained()
-                        .with_style(theme.contact_username.container)
-                        .aligned()
-                        .left()
-                        .flex(1., true)
-                        .boxed(),
-                    )
-                    .constrained()
-                    .with_height(theme.row_height)
-                    .contained()
-                    .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
-                    .boxed()
-            })
-            .on_click(MouseButton::Left, move |_, cx| {
-                if online && !busy {
-                    cx.dispatch_action(Call {
-                        recipient_user_id: user_id,
-                        initial_project: Some(initial_project.clone()),
-                    });
-                }
-            });
-
-        if online {
-            element = element.with_cursor_style(CursorStyle::PointingHand);
-        }
-
-        element.boxed()
-    }
-
-    fn render_contact_request(
-        user: Arc<User>,
-        user_store: ModelHandle<UserStore>,
-        theme: &theme::ContactsPopover,
-        is_incoming: bool,
-        is_selected: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        enum Decline {}
-        enum Accept {}
-        enum Cancel {}
-
-        let mut row = Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-                    .boxed()
-            }))
-            .with_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.contact_username.text.clone(),
-                )
-                .contained()
-                .with_style(theme.contact_username.container)
-                .aligned()
-                .left()
-                .flex(1., true)
-                .boxed(),
-            );
-
-        let user_id = user.id;
-        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
-        let button_spacing = theme.contact_button_spacing;
-
-        if is_incoming {
-            row.add_children([
-                MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state, false)
-                    };
-                    render_icon_button(button_style, "icons/x_mark_8.svg")
-                        .aligned()
-                        .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RespondToContactRequest {
-                        user_id,
-                        accept: false,
-                    })
-                })
-                .contained()
-                .with_margin_right(button_spacing)
-                .boxed(),
-                MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state, false)
-                    };
-                    render_icon_button(button_style, "icons/check_8.svg")
-                        .aligned()
-                        .flex_float()
-                        .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RespondToContactRequest {
-                        user_id,
-                        accept: true,
-                    })
-                })
-                .boxed(),
-            ]);
-        } else {
-            row.add_child(
-                MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state, false)
-                    };
-                    render_icon_button(button_style, "icons/x_mark_8.svg")
-                        .aligned()
-                        .flex_float()
-                        .boxed()
-                })
-                .with_padding(Padding::uniform(2.))
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RemoveContact(user_id))
-                })
-                .flex_float()
-                .boxed(),
-            );
-        }
-
-        row.constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
-            .boxed()
-    }
-
-    fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
-        let recipient_user_id = action.recipient_user_id;
-        let initial_project = action.initial_project.clone();
-        let window_id = cx.window_id();
-
-        let active_call = ActiveCall::global(cx);
-        cx.spawn_weak(|_, mut cx| async move {
-            active_call
-                .update(&mut cx, |active_call, cx| {
-                    active_call.invite(recipient_user_id, initial_project.clone(), cx)
-                })
-                .await?;
-            if cx.update(|cx| cx.window_is_active(window_id)) {
-                active_call
-                    .update(&mut cx, |call, cx| {
-                        call.set_location(initial_project.as_ref(), cx)
-                    })
-                    .await?;
-            }
-            anyhow::Ok(())
-        })
-        .detach_and_log_err(cx);
     }
 }
 
@@ -858,97 +85,14 @@ impl View for ContactsPopover {
         "ContactsPopover"
     }
 
-    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
-        let mut cx = Self::default_keymap_context();
-        cx.set.insert("menu".into());
-        cx
-    }
-
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        enum AddContact {}
         let theme = cx.global::<Settings>().theme.clone();
+        let child = match &self.child {
+            Child::ContactList(child) => ChildView::new(child),
+            Child::ContactFinder(child) => ChildView::new(child),
+        };
 
-        Flex::column()
-            .with_child(
-                Flex::row()
-                    .with_child(
-                        ChildView::new(self.filter_editor.clone())
-                            .contained()
-                            .with_style(theme.contacts_popover.user_query_editor.container)
-                            .flex(1., true)
-                            .boxed(),
-                    )
-                    .with_child(
-                        MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
-                            Svg::new("icons/user_plus_16.svg")
-                                .with_color(theme.contacts_popover.add_contact_button.color)
-                                .constrained()
-                                .with_height(16.)
-                                .contained()
-                                .with_style(theme.contacts_popover.add_contact_button.container)
-                                .aligned()
-                                .boxed()
-                        })
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(MouseButton::Left, |_, cx| {
-                            cx.dispatch_action(contact_finder::Toggle)
-                        })
-                        .boxed(),
-                    )
-                    .constrained()
-                    .with_height(theme.contacts_popover.user_query_editor_height)
-                    .boxed(),
-            )
-            .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
-            .with_children(
-                self.user_store
-                    .read(cx)
-                    .invite_info()
-                    .cloned()
-                    .and_then(|info| {
-                        enum InviteLink {}
-
-                        if info.count > 0 {
-                            Some(
-                                MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
-                                    let style = theme
-                                        .contacts_popover
-                                        .invite_row
-                                        .style_for(state, false)
-                                        .clone();
-
-                                    let copied = cx.read_from_clipboard().map_or(false, |item| {
-                                        item.text().as_str() == info.url.as_ref()
-                                    });
-
-                                    Label::new(
-                                        format!(
-                                            "{} invite link ({} left)",
-                                            if copied { "Copied" } else { "Copy" },
-                                            info.count
-                                        ),
-                                        style.label.clone(),
-                                    )
-                                    .aligned()
-                                    .left()
-                                    .constrained()
-                                    .with_height(theme.contacts_popover.row_height)
-                                    .contained()
-                                    .with_style(style.container)
-                                    .boxed()
-                                })
-                                .with_cursor_style(CursorStyle::PointingHand)
-                                .on_click(MouseButton::Left, move |_, cx| {
-                                    cx.write_to_clipboard(ClipboardItem::new(info.url.to_string()));
-                                    cx.notify();
-                                })
-                                .boxed(),
-                            )
-                        } else {
-                            None
-                        }
-                    }),
-            )
+        child
             .contained()
             .with_style(theme.contacts_popover.container)
             .constrained()
@@ -958,27 +102,11 @@ impl View for ContactsPopover {
     }
 
     fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
-        if !self.filter_editor.is_focused(cx) {
-            cx.focus(&self.filter_editor);
-        }
-    }
-
-    fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
-        if !self.filter_editor.is_focused(cx) {
-            cx.emit(Event::Dismissed);
+        if cx.is_self_focused() {
+            match &self.child {
+                Child::ContactList(child) => cx.focus(child),
+                Child::ContactFinder(child) => cx.focus(child),
+            }
         }
     }
 }
-
-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()
-        .contained()
-        .with_style(style.container)
-        .constrained()
-        .with_width(style.button_width)
-        .with_height(style.button_width)
-}

crates/collab_ui/src/incoming_call_notification.rs 🔗

@@ -82,20 +82,22 @@ impl IncomingCallNotification {
     }
 
     fn render_caller(&self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = &cx.global::<Settings>().theme.contacts_popover;
+        let theme = &cx.global::<Settings>().theme.incoming_call_notification;
         Flex::row()
             .with_children(
                 self.call
                     .caller
                     .avatar
                     .clone()
-                    .map(|avatar| Image::new(avatar).with_style(theme.contact_avatar).boxed()),
+                    .map(|avatar| Image::new(avatar).with_style(theme.caller_avatar).boxed()),
             )
             .with_child(
                 Label::new(
                     self.call.caller.github_login.clone(),
-                    theme.contact_username.text.clone(),
+                    theme.caller_username.text.clone(),
                 )
+                .contained()
+                .with_style(theme.caller_username.container)
                 .boxed(),
             )
             .boxed()
@@ -108,8 +110,11 @@ impl IncomingCallNotification {
         Flex::row()
             .with_child(
                 MouseEventHandler::<Accept>::new(0, cx, |_, cx| {
-                    let theme = &cx.global::<Settings>().theme.contacts_popover;
-                    Label::new("Accept".to_string(), theme.contact_username.text.clone()).boxed()
+                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    Label::new("Accept".to_string(), theme.accept_button.text.clone())
+                        .contained()
+                        .with_style(theme.accept_button.container)
+                        .boxed()
                 })
                 .on_click(MouseButton::Left, |_, cx| {
                     cx.dispatch_action(RespondToCall { accept: true });
@@ -118,8 +123,11 @@ impl IncomingCallNotification {
             )
             .with_child(
                 MouseEventHandler::<Decline>::new(0, cx, |_, cx| {
-                    let theme = &cx.global::<Settings>().theme.contacts_popover;
-                    Label::new("Decline".to_string(), theme.contact_username.text.clone()).boxed()
+                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    Label::new("Decline".to_string(), theme.decline_button.text.clone())
+                        .contained()
+                        .with_style(theme.decline_button.container)
+                        .boxed()
                 })
                 .on_click(MouseButton::Left, |_, cx| {
                     cx.dispatch_action(RespondToCall { accept: false });

crates/picker/src/picker.rs 🔗

@@ -19,6 +19,7 @@ pub struct Picker<D: PickerDelegate> {
     query_editor: ViewHandle<Editor>,
     list_state: UniformListState,
     max_size: Vector2F,
+    theme: Box<dyn FnMut(&AppContext) -> &theme::Picker>,
     confirmed: bool,
 }
 
@@ -51,8 +52,8 @@ impl<D: PickerDelegate> View for Picker<D> {
     }
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
-        let settings = cx.global::<Settings>();
-        let container_style = settings.theme.picker.container;
+        let theme = (self.theme)(cx);
+        let container_style = theme.container;
         let delegate = self.delegate.clone();
         let match_count = if let Some(delegate) = delegate.upgrade(cx.app) {
             delegate.read(cx).match_count()
@@ -64,17 +65,14 @@ impl<D: PickerDelegate> View for Picker<D> {
             .with_child(
                 ChildView::new(&self.query_editor)
                     .contained()
-                    .with_style(settings.theme.picker.input_editor.container)
+                    .with_style(theme.input_editor.container)
                     .boxed(),
             )
             .with_child(
                 if match_count == 0 {
-                    Label::new(
-                        "No matches".into(),
-                        settings.theme.picker.empty.label.clone(),
-                    )
-                    .contained()
-                    .with_style(settings.theme.picker.empty.container)
+                    Label::new("No matches".into(), theme.empty.label.clone())
+                        .contained()
+                        .with_style(theme.empty.container)
                 } else {
                     UniformList::new(
                         self.list_state.clone(),
@@ -147,6 +145,7 @@ impl<D: PickerDelegate> Picker<D> {
             list_state: Default::default(),
             delegate,
             max_size: vec2f(540., 420.),
+            theme: Box::new(|cx| &cx.global::<Settings>().theme.picker),
             confirmed: false,
         };
         cx.defer(|this, cx| {
@@ -163,6 +162,14 @@ impl<D: PickerDelegate> Picker<D> {
         self
     }
 
+    pub fn with_theme<F>(mut self, theme: F) -> Self
+    where
+        F: 'static + FnMut(&AppContext) -> &theme::Picker,
+    {
+        self.theme = Box::new(theme);
+        self
+    }
+
     pub fn query(&self, cx: &AppContext) -> String {
         self.query_editor.read(cx).text(cx)
     }

crates/theme/src/theme.rs 🔗

@@ -20,6 +20,7 @@ pub struct Theme {
     pub context_menu: ContextMenu,
     pub chat_panel: ChatPanel,
     pub contacts_popover: ContactsPopover,
+    pub contact_list: ContactList,
     pub contact_finder: ContactFinder,
     pub project_panel: ProjectPanel,
     pub command_palette: CommandPalette,
@@ -31,6 +32,7 @@ pub struct Theme {
     pub contact_notification: ContactNotification,
     pub update_notification: UpdateNotification,
     pub project_shared_notification: ProjectSharedNotification,
+    pub incoming_call_notification: IncomingCallNotification,
     pub tooltip: TooltipStyle,
     pub terminal: TerminalStyle,
 }
@@ -87,6 +89,10 @@ pub struct ContactsPopover {
     pub container: ContainerStyle,
     pub height: f32,
     pub width: f32,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactList {
     pub user_query_editor: FieldEditor,
     pub user_query_editor_height: f32,
     pub add_contact_button: IconButton,
@@ -105,6 +111,16 @@ pub struct ContactsPopover {
     pub calling_indicator: ContainedText,
 }
 
+#[derive(Deserialize, Default)]
+pub struct ContactFinder {
+    pub picker: Picker,
+    pub row_height: f32,
+    pub contact_avatar: ImageStyle,
+    pub contact_username: ContainerStyle,
+    pub contact_button: IconButton,
+    pub disabled_contact_button: IconButton,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct TabBar {
     #[serde(flatten)]
@@ -353,15 +369,6 @@ pub struct InviteLink {
     pub icon: Icon,
 }
 
-#[derive(Deserialize, Default)]
-pub struct ContactFinder {
-    pub row_height: f32,
-    pub contact_avatar: ImageStyle,
-    pub contact_username: ContainerStyle,
-    pub contact_button: IconButton,
-    pub disabled_contact_button: IconButton,
-}
-
 #[derive(Deserialize, Default)]
 pub struct Icon {
     #[serde(flatten)]
@@ -469,6 +476,14 @@ pub struct ProjectSharedNotification {
     pub dismiss_button: ContainedText,
 }
 
+#[derive(Deserialize, Default)]
+pub struct IncomingCallNotification {
+    pub caller_avatar: ImageStyle,
+    pub caller_username: ContainedText,
+    pub accept_button: ContainedText,
+    pub decline_button: ContainedText,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct Editor {
     pub text_color: Color,

styles/src/styleTree/app.ts 🔗

@@ -16,6 +16,8 @@ import updateNotification from "./updateNotification";
 import projectSharedNotification from "./projectSharedNotification";
 import tooltip from "./tooltip";
 import terminal from "./terminal";
+import contactList from "./contactList";
+import incomingCallNotification from "./incomingCallNotification";
 
 export const panel = {
   padding: { top: 12, bottom: 12 },
@@ -36,6 +38,7 @@ export default function app(theme: Theme): Object {
     projectPanel: projectPanel(theme),
     chatPanel: chatPanel(theme),
     contactsPopover: contactsPopover(theme),
+    contactList: contactList(theme),
     contactFinder: contactFinder(theme),
     search: search(theme),
     breadcrumbs: {
@@ -47,6 +50,7 @@ export default function app(theme: Theme): Object {
     contactNotification: contactNotification(theme),
     updateNotification: updateNotification(theme),
     projectSharedNotification: projectSharedNotification(theme),
+    incomingCallNotification: incomingCallNotification(theme),
     tooltip: tooltip(theme),
     terminal: terminal(theme),
   };

styles/src/styleTree/contactFinder.ts 🔗

@@ -1,6 +1,6 @@
 import Theme from "../themes/common/theme";
 import picker from "./picker";
-import { backgroundColor, iconColor } from "./components";
+import { backgroundColor, border, iconColor, player, text } from "./components";
 
 export default function contactFinder(theme: Theme) {
   const contactButton = {
@@ -12,7 +12,28 @@ export default function contactFinder(theme: Theme) {
   };
 
   return {
-    ...picker(theme),
+    picker: {
+      item: picker(theme).item,
+      empty: picker(theme).empty,
+      inputEditor: {
+        background: backgroundColor(theme, 500),
+        cornerRadius: 6,
+        text: text(theme, "mono", "primary"),
+        placeholderText: text(theme, "mono", "placeholder", { size: "sm" }),
+        selection: player(theme, 1).selection,
+        border: border(theme, "secondary"),
+        padding: {
+          bottom: 4,
+          left: 8,
+          right: 8,
+          top: 4,
+        },
+        margin: {
+          left: 12,
+          right: 12,
+        }
+      }
+    },
     rowHeight: 28,
     contactAvatar: {
       cornerRadius: 10,

styles/src/styleTree/contactsPopover.ts 🔗

@@ -19,7 +19,6 @@ export default function contactsPopover(theme: Theme) {
     padding: { top: 6 },
     shadow: popoverShadow(theme),
     border: border(theme, "primary"),
-    margin: { top: -5 },
     width: 250,
     height: 300,
     userQueryEditor: {