Add leave button on active call header

Antonio Scandurra created

Change summary

crates/call/src/call.rs                          |    1 
crates/collab_ui/src/contact_list.rs             | 1008 ++++++++++++++++++
crates/theme/src/theme.rs                        |    1 
styles/src/styleTree/contactList.ts              |  134 ++
styles/src/styleTree/incomingCallNotification.ts |   22 
5 files changed, 1,166 insertions(+)

Detailed changes

crates/call/src/call.rs 🔗

@@ -201,6 +201,7 @@ impl ActiveCall {
     pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
         if let Some((room, _)) = self.room.take() {
             room.update(cx, |room, cx| room.leave(cx))?;
+            cx.notify();
         }
         Ok(())
     }

crates/collab_ui/src/contact_list.rs 🔗

@@ -0,0 +1,1008 @@
+use std::sync::Arc;
+
+use crate::contacts_popover;
+use call::ActiveCall;
+use client::{Contact, PeerId, User, UserStore};
+use editor::{Cancel, Editor};
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+    elements::*, impl_actions, impl_internal_actions, keymap, AppContext, ClipboardItem,
+    CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription,
+    View, ViewContext, ViewHandle,
+};
+use menu::{Confirm, SelectNext, SelectPrev};
+use project::Project;
+use serde::Deserialize;
+use settings::Settings;
+use theme::IconButton;
+use util::ResultExt;
+
+impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
+impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ContactList::remove_contact);
+    cx.add_action(ContactList::respond_to_contact_request);
+    cx.add_action(ContactList::clear_filter);
+    cx.add_action(ContactList::select_next);
+    cx.add_action(ContactList::select_prev);
+    cx.add_action(ContactList::confirm);
+    cx.add_action(ContactList::toggle_expanded);
+    cx.add_action(ContactList::call);
+    cx.add_action(ContactList::leave_call);
+}
+
+#[derive(Clone, PartialEq)]
+struct ToggleExpanded(Section);
+
+#[derive(Clone, PartialEq)]
+struct Call {
+    recipient_user_id: u64,
+    initial_project: Option<ModelHandle<Project>>,
+}
+
+#[derive(Copy, Clone, PartialEq)]
+struct LeaveCall;
+
+#[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,
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+pub struct ContactList {
+    entries: Vec<ContactEntry>,
+    match_candidates: Vec<StringMatchCandidate>,
+    list_state: ListState,
+    project: ModelHandle<Project>,
+    user_store: ModelHandle<UserStore>,
+    filter_editor: ViewHandle<Editor>,
+    collapsed_sections: Vec<Section>,
+    selection: Option<usize>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl ContactList {
+    pub fn new(
+        project: ModelHandle<Project>,
+        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.contact_list.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.contact_list,
+                        is_selected,
+                        is_collapsed,
+                        cx,
+                    )
+                }
+                ContactEntry::CallParticipant { user, is_pending } => {
+                    Self::render_call_participant(
+                        user,
+                        *is_pending,
+                        is_selected,
+                        &theme.contact_list,
+                    )
+                }
+                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    &theme.contact_list,
+                    true,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    &theme.contact_list,
+                    false,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::Contact(contact) => Self::render_contact(
+                    contact,
+                    &this.project,
+                    &theme.contact_list,
+                    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,
+            project,
+            user_store,
+        };
+        this.update_entries(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 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);
+        }
+        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;
+            }
+        }
+        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::ContactList,
+    ) -> 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::ContactList,
+        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 leave_call = if section == Section::ActiveCall {
+            Some(
+                MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
+                    let style = theme.leave_call.style_for(state, false);
+                    Label::new("Leave".into(), style.text.clone())
+                        .contained()
+                        .with_style(style.container)
+                        .boxed()
+                })
+                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
+                .aligned()
+                .boxed(),
+            )
+        } else {
+            None
+        };
+
+        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(),
+                )
+                .with_children(leave_call)
+                .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::ContactList,
+        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::ContactList,
+        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);
+    }
+
+    fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.hang_up(cx))
+            .log_err();
+    }
+}
+
+impl Entity for ContactList {
+    type Event = Event;
+}
+
+impl View for ContactList {
+    fn ui_name() -> &'static str {
+        "ContactList"
+    }
+
+    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();
+
+        Flex::column()
+            .with_child(
+                Flex::row()
+                    .with_child(
+                        ChildView::new(self.filter_editor.clone())
+                            .contained()
+                            .with_style(theme.contact_list.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.contact_list.add_contact_button.color)
+                                .constrained()
+                                .with_height(16.)
+                                .contained()
+                                .with_style(theme.contact_list.add_contact_button.container)
+                                .aligned()
+                                .boxed()
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(MouseButton::Left, |_, cx| {
+                            cx.dispatch_action(contacts_popover::ToggleContactFinder)
+                        })
+                        .boxed(),
+                    )
+                    .constrained()
+                    .with_height(theme.contact_list.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
+                                        .contact_list
+                                        .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.contact_list.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
+                        }
+                    }),
+            )
+            .boxed()
+    }
+
+    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);
+        }
+    }
+}
+
+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/theme/src/theme.rs 🔗

@@ -97,6 +97,7 @@ pub struct ContactList {
     pub user_query_editor_height: f32,
     pub add_contact_button: IconButton,
     pub header_row: Interactive<ContainedText>,
+    pub leave_call: Interactive<ContainedText>,
     pub contact_row: Interactive<ContainerStyle>,
     pub row_height: f32,
     pub contact_avatar: ImageStyle,

styles/src/styleTree/contactList.ts 🔗

@@ -0,0 +1,134 @@
+import Theme from "../themes/common/theme";
+import { backgroundColor, border, borderColor, iconColor, player, text } from "./components";
+
+export default function contactList(theme: Theme) {
+  const nameMargin = 8;
+  const sidePadding = 12;
+
+  const contactButton = {
+    background: backgroundColor(theme, 100),
+    color: iconColor(theme, "primary"),
+    iconWidth: 8,
+    buttonWidth: 16,
+    cornerRadius: 8,
+  };
+
+  return {
+    userQueryEditor: {
+      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: sidePadding,
+        right: sidePadding,
+      },
+    },
+    userQueryEditorHeight: 33,
+    addContactButton: {
+      margin: { left: 6, right: 12 },
+      color: iconColor(theme, "primary"),
+      buttonWidth: 16,
+      iconWidth: 16,
+    },
+    rowHeight: 28,
+    sectionIconSize: 8,
+    headerRow: {
+      ...text(theme, "mono", "secondary", { size: "sm" }),
+      margin: { top: 14 },
+      padding: {
+        left: sidePadding,
+        right: sidePadding,
+      },
+      active: {
+        ...text(theme, "mono", "primary", { size: "sm" }),
+        background: backgroundColor(theme, 100, "active"),
+      },
+    },
+    leaveCall: {
+      background: backgroundColor(theme, 100),
+      border: border(theme, "secondary"),
+      cornerRadius: 6,
+      margin: {
+        top: 1,
+      },
+      padding: {
+        top: 1,
+        bottom: 1,
+        left: 7,
+        right: 7,
+      },
+      ...text(theme, "sans", "secondary", { size: "xs" }),
+      hover: {
+        ...text(theme, "sans", "active", { size: "xs" }),
+        background: backgroundColor(theme, "on300", "hovered"),
+        border: border(theme, "primary"),
+      },
+    },
+    contactRow: {
+      padding: {
+        left: sidePadding,
+        right: sidePadding,
+      },
+      active: {
+        background: backgroundColor(theme, 100, "active"),
+      },
+    },
+    contactAvatar: {
+      cornerRadius: 10,
+      width: 18,
+    },
+    contactStatusFree: {
+      cornerRadius: 4,
+      padding: 4,
+      margin: { top: 12, left: 12 },
+      background: iconColor(theme, "success"),
+    },
+    contactStatusBusy: {
+      cornerRadius: 4,
+      padding: 4,
+      margin: { top: 12, left: 12 },
+      background: iconColor(theme, "warning"),
+    },
+    contactUsername: {
+      ...text(theme, "mono", "primary", { size: "sm" }),
+      margin: {
+        left: nameMargin,
+      },
+    },
+    contactButtonSpacing: nameMargin,
+    contactButton: {
+      ...contactButton,
+      hover: {
+        background: backgroundColor(theme, "on300", "hovered"),
+      },
+    },
+    disabledButton: {
+      ...contactButton,
+      background: backgroundColor(theme, 100),
+      color: iconColor(theme, "muted"),
+    },
+    inviteRow: {
+      padding: {
+        left: sidePadding,
+        right: sidePadding,
+      },
+      border: { top: true, width: 1, color: borderColor(theme, "primary") },
+      text: text(theme, "sans", "secondary", { size: "sm" }),
+      hover: {
+        text: text(theme, "sans", "active", { size: "sm" }),
+      },
+    },
+    callingIndicator: {
+      ...text(theme, "mono", "muted", { size: "xs" })
+    }
+  }
+}

styles/src/styleTree/incomingCallNotification.ts 🔗

@@ -0,0 +1,22 @@
+import Theme from "../themes/common/theme";
+import { text } from "./components";
+
+export default function incomingCallNotification(theme: Theme): Object {
+  const avatarSize = 12;
+  return {
+    callerAvatar: {
+      height: avatarSize,
+      width: avatarSize,
+      cornerRadius: 6,
+    },
+    callerUsername: {
+      ...text(theme, "sans", "primary", { size: "xs" }),
+    },
+    acceptButton: {
+      ...text(theme, "sans", "primary", { size: "xs" })
+    },
+    declineButton: {
+      ...text(theme, "sans", "primary", { size: "xs" })
+    },
+  };
+}