diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 7015173ce7..99edb33b6e 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -201,6 +201,7 @@ impl ActiveCall { pub fn hang_up(&mut self, cx: &mut ModelContext) -> Result<()> { if let Some((room, _)) = self.room.take() { room.update(cx, |room, cx| room.leave(cx))?; + cx.notify(); } Ok(()) } diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs new file mode 100644 index 0000000000..a539f8ffac --- /dev/null +++ b/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>, +} + +#[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, is_pending: bool }, + IncomingRequest(Arc), + OutgoingRequest(Arc), + Contact(Arc), +} + +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, + match_candidates: Vec, + list_state: ListState, + project: ModelHandle, + user_store: ModelHandle, + filter_editor: ViewHandle, + collapsed_sections: Vec
, + selection: Option, + _subscriptions: Vec, +} + +impl ContactList { + pub fn new( + project: ModelHandle, + user_store: ModelHandle, + cx: &mut ViewContext, + ) -> 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::().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.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.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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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::, _>(|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(§ion) { + 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, + ) -> 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::::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::
::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, + theme: &theme::ContactList, + is_selected: bool, + cx: &mut RenderContext, + ) -> ElementBox { + let online = contact.online; + let busy = contact.busy; + let user_id = contact.user.id; + let initial_project = project.clone(); + let mut element = + MouseEventHandler::::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_store: ModelHandle, + theme: &theme::ContactList, + is_incoming: bool, + is_selected: bool, + cx: &mut RenderContext, + ) -> 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::::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::::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::::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) { + 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) { + 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) -> ElementBox { + enum AddContact {} + let theme = cx.global::().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::::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::::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) { + if !self.filter_editor.is_focused(cx) { + cx.focus(&self.filter_editor); + } + } + + fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + 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) +} diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 268a655c23..d69720322e 100644 --- a/crates/theme/src/theme.rs +++ b/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, + pub leave_call: Interactive, pub contact_row: Interactive, pub row_height: f32, pub contact_avatar: ImageStyle, diff --git a/styles/src/styleTree/contactList.ts b/styles/src/styleTree/contactList.ts new file mode 100644 index 0000000000..f2430d31e6 --- /dev/null +++ b/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" }) + } + } +} diff --git a/styles/src/styleTree/incomingCallNotification.ts b/styles/src/styleTree/incomingCallNotification.ts new file mode 100644 index 0000000000..dcae47bff0 --- /dev/null +++ b/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" }) + }, + }; +}