1mod channel_modal;
2mod contact_finder;
3mod panel_settings;
4
5use anyhow::Result;
6use call::ActiveCall;
7use client::{proto::PeerId, Client, Contact, User, UserStore};
8use contact_finder::build_contact_finder;
9use context_menu::ContextMenu;
10use db::kvp::KEY_VALUE_STORE;
11use editor::{Cancel, Editor};
12use futures::StreamExt;
13use fuzzy::{match_strings, StringMatchCandidate};
14use gpui::{
15 actions,
16 elements::{
17 Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState,
18 MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg,
19 },
20 geometry::{rect::RectF, vector::vec2f},
21 platform::{CursorStyle, MouseButton, PromptLevel},
22 serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle,
23 Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
24};
25use menu::{Confirm, SelectNext, SelectPrev};
26use panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings};
27use project::{Fs, Project};
28use serde_derive::{Deserialize, Serialize};
29use settings::SettingsStore;
30use std::{mem, sync::Arc};
31use theme::IconButton;
32use util::{ResultExt, TryFutureExt};
33use workspace::{
34 dock::{DockPosition, Panel},
35 Workspace,
36};
37
38use self::channel_modal::ChannelModal;
39
40actions!(collab_panel, [ToggleFocus]);
41
42const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel";
43
44pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
45 settings::register::<panel_settings::ChannelsPanelSettings>(cx);
46 contact_finder::init(cx);
47 channel_modal::init(cx);
48
49 cx.add_action(CollabPanel::cancel);
50 cx.add_action(CollabPanel::select_next);
51 cx.add_action(CollabPanel::select_prev);
52 cx.add_action(CollabPanel::confirm);
53}
54
55pub struct CollabPanel {
56 width: Option<f32>,
57 fs: Arc<dyn Fs>,
58 has_focus: bool,
59 pending_serialization: Task<Option<()>>,
60 context_menu: ViewHandle<ContextMenu>,
61 filter_editor: ViewHandle<Editor>,
62 entries: Vec<ContactEntry>,
63 selection: Option<usize>,
64 user_store: ModelHandle<UserStore>,
65 project: ModelHandle<Project>,
66 match_candidates: Vec<StringMatchCandidate>,
67 list_state: ListState<Self>,
68 subscriptions: Vec<Subscription>,
69 collapsed_sections: Vec<Section>,
70 workspace: WeakViewHandle<Workspace>,
71}
72
73#[derive(Serialize, Deserialize)]
74struct SerializedChannelsPanel {
75 width: Option<f32>,
76}
77
78#[derive(Debug)]
79pub enum Event {
80 DockPositionChanged,
81 Focus,
82 Dismissed,
83}
84
85#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
86enum Section {
87 ActiveCall,
88 Channels,
89 Requests,
90 Contacts,
91 Online,
92 Offline,
93}
94
95#[derive(Clone)]
96enum ContactEntry {
97 Header(Section, usize),
98 CallParticipant {
99 user: Arc<User>,
100 is_pending: bool,
101 },
102 ParticipantProject {
103 project_id: u64,
104 worktree_root_names: Vec<String>,
105 host_user_id: u64,
106 is_last: bool,
107 },
108 ParticipantScreen {
109 peer_id: PeerId,
110 is_last: bool,
111 },
112 IncomingRequest(Arc<User>),
113 OutgoingRequest(Arc<User>),
114 Contact {
115 contact: Arc<Contact>,
116 calling: bool,
117 },
118}
119
120impl Entity for CollabPanel {
121 type Event = Event;
122}
123
124impl CollabPanel {
125 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
126 cx.add_view::<Self, _>(|cx| {
127 let view_id = cx.view_id();
128
129 let filter_editor = cx.add_view(|cx| {
130 let mut editor = Editor::single_line(
131 Some(Arc::new(|theme| {
132 theme.collab_panel.user_query_editor.clone()
133 })),
134 cx,
135 );
136 editor.set_placeholder_text("Filter channels, contacts", cx);
137 editor
138 });
139
140 cx.subscribe(&filter_editor, |this, _, event, cx| {
141 if let editor::Event::BufferEdited = event {
142 let query = this.filter_editor.read(cx).text(cx);
143 if !query.is_empty() {
144 this.selection.take();
145 }
146 this.update_entries(cx);
147 if !query.is_empty() {
148 this.selection = this
149 .entries
150 .iter()
151 .position(|entry| !matches!(entry, ContactEntry::Header(_, _)));
152 }
153 }
154 })
155 .detach();
156
157 let list_state =
158 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
159 let theme = theme::current(cx).clone();
160 let is_selected = this.selection == Some(ix);
161 let current_project_id = this.project.read(cx).remote_id();
162
163 match &this.entries[ix] {
164 ContactEntry::Header(section, depth) => {
165 let is_collapsed = this.collapsed_sections.contains(section);
166 Self::render_header(
167 *section,
168 &theme,
169 *depth,
170 is_selected,
171 is_collapsed,
172 cx,
173 )
174 }
175 ContactEntry::CallParticipant { user, is_pending } => {
176 Self::render_call_participant(
177 user,
178 *is_pending,
179 is_selected,
180 &theme.collab_panel,
181 )
182 }
183 ContactEntry::ParticipantProject {
184 project_id,
185 worktree_root_names,
186 host_user_id,
187 is_last,
188 } => Self::render_participant_project(
189 *project_id,
190 worktree_root_names,
191 *host_user_id,
192 Some(*project_id) == current_project_id,
193 *is_last,
194 is_selected,
195 &theme.collab_panel,
196 cx,
197 ),
198 ContactEntry::ParticipantScreen { peer_id, is_last } => {
199 Self::render_participant_screen(
200 *peer_id,
201 *is_last,
202 is_selected,
203 &theme.collab_panel,
204 cx,
205 )
206 }
207 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
208 user.clone(),
209 this.user_store.clone(),
210 &theme.collab_panel,
211 true,
212 is_selected,
213 cx,
214 ),
215 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
216 user.clone(),
217 this.user_store.clone(),
218 &theme.collab_panel,
219 false,
220 is_selected,
221 cx,
222 ),
223 ContactEntry::Contact { contact, calling } => Self::render_contact(
224 contact,
225 *calling,
226 &this.project,
227 &theme.collab_panel,
228 is_selected,
229 cx,
230 ),
231 }
232 });
233
234 let mut this = Self {
235 width: None,
236 has_focus: false,
237 fs: workspace.app_state().fs.clone(),
238 pending_serialization: Task::ready(None),
239 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
240 filter_editor,
241 entries: Vec::default(),
242 selection: None,
243 user_store: workspace.user_store().clone(),
244 project: workspace.project().clone(),
245 subscriptions: Vec::default(),
246 match_candidates: Vec::default(),
247 collapsed_sections: Vec::default(),
248 workspace: workspace.weak_handle(),
249 list_state,
250 };
251 this.update_entries(cx);
252
253 // Update the dock position when the setting changes.
254 let mut old_dock_position = this.position(cx);
255 this.subscriptions
256 .push(
257 cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
258 let new_dock_position = this.position(cx);
259 if new_dock_position != old_dock_position {
260 old_dock_position = new_dock_position;
261 cx.emit(Event::DockPositionChanged);
262 }
263 }),
264 );
265
266 let active_call = ActiveCall::global(cx);
267 this.subscriptions
268 .push(cx.observe(&this.user_store, |this, _, cx| this.update_entries(cx)));
269 this.subscriptions
270 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
271
272 this
273 })
274 }
275
276 pub fn load(
277 workspace: WeakViewHandle<Workspace>,
278 cx: AsyncAppContext,
279 ) -> Task<Result<ViewHandle<Self>>> {
280 cx.spawn(|mut cx| async move {
281 let serialized_panel = if let Some(panel) = cx
282 .background()
283 .spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) })
284 .await
285 .log_err()
286 .flatten()
287 {
288 Some(serde_json::from_str::<SerializedChannelsPanel>(&panel)?)
289 } else {
290 None
291 };
292
293 workspace.update(&mut cx, |workspace, cx| {
294 let panel = CollabPanel::new(workspace, cx);
295 if let Some(serialized_panel) = serialized_panel {
296 panel.update(cx, |panel, cx| {
297 panel.width = serialized_panel.width;
298 cx.notify();
299 });
300 }
301 panel
302 })
303 })
304 }
305
306 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
307 let width = self.width;
308 self.pending_serialization = cx.background().spawn(
309 async move {
310 KEY_VALUE_STORE
311 .write_kvp(
312 CHANNELS_PANEL_KEY.into(),
313 serde_json::to_string(&SerializedChannelsPanel { width })?,
314 )
315 .await?;
316 anyhow::Ok(())
317 }
318 .log_err(),
319 );
320 }
321
322 fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
323 let user_store = self.user_store.read(cx);
324 let query = self.filter_editor.read(cx).text(cx);
325 let executor = cx.background().clone();
326
327 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
328 let old_entries = mem::take(&mut self.entries);
329
330 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
331 let room = room.read(cx);
332 let mut participant_entries = Vec::new();
333
334 // Populate the active user.
335 if let Some(user) = user_store.current_user() {
336 self.match_candidates.clear();
337 self.match_candidates.push(StringMatchCandidate {
338 id: 0,
339 string: user.github_login.clone(),
340 char_bag: user.github_login.chars().collect(),
341 });
342 let matches = executor.block(match_strings(
343 &self.match_candidates,
344 &query,
345 true,
346 usize::MAX,
347 &Default::default(),
348 executor.clone(),
349 ));
350 if !matches.is_empty() {
351 let user_id = user.id;
352 participant_entries.push(ContactEntry::CallParticipant {
353 user,
354 is_pending: false,
355 });
356 let mut projects = room.local_participant().projects.iter().peekable();
357 while let Some(project) = projects.next() {
358 participant_entries.push(ContactEntry::ParticipantProject {
359 project_id: project.id,
360 worktree_root_names: project.worktree_root_names.clone(),
361 host_user_id: user_id,
362 is_last: projects.peek().is_none(),
363 });
364 }
365 }
366 }
367
368 // Populate remote participants.
369 self.match_candidates.clear();
370 self.match_candidates
371 .extend(room.remote_participants().iter().map(|(_, participant)| {
372 StringMatchCandidate {
373 id: participant.user.id as usize,
374 string: participant.user.github_login.clone(),
375 char_bag: participant.user.github_login.chars().collect(),
376 }
377 }));
378 let matches = executor.block(match_strings(
379 &self.match_candidates,
380 &query,
381 true,
382 usize::MAX,
383 &Default::default(),
384 executor.clone(),
385 ));
386 for mat in matches {
387 let user_id = mat.candidate_id as u64;
388 let participant = &room.remote_participants()[&user_id];
389 participant_entries.push(ContactEntry::CallParticipant {
390 user: participant.user.clone(),
391 is_pending: false,
392 });
393 let mut projects = participant.projects.iter().peekable();
394 while let Some(project) = projects.next() {
395 participant_entries.push(ContactEntry::ParticipantProject {
396 project_id: project.id,
397 worktree_root_names: project.worktree_root_names.clone(),
398 host_user_id: participant.user.id,
399 is_last: projects.peek().is_none() && participant.video_tracks.is_empty(),
400 });
401 }
402 if !participant.video_tracks.is_empty() {
403 participant_entries.push(ContactEntry::ParticipantScreen {
404 peer_id: participant.peer_id,
405 is_last: true,
406 });
407 }
408 }
409
410 // Populate pending participants.
411 self.match_candidates.clear();
412 self.match_candidates
413 .extend(
414 room.pending_participants()
415 .iter()
416 .enumerate()
417 .map(|(id, participant)| StringMatchCandidate {
418 id,
419 string: participant.github_login.clone(),
420 char_bag: participant.github_login.chars().collect(),
421 }),
422 );
423 let matches = executor.block(match_strings(
424 &self.match_candidates,
425 &query,
426 true,
427 usize::MAX,
428 &Default::default(),
429 executor.clone(),
430 ));
431 participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
432 user: room.pending_participants()[mat.candidate_id].clone(),
433 is_pending: true,
434 }));
435
436 if !participant_entries.is_empty() {
437 self.entries
438 .push(ContactEntry::Header(Section::ActiveCall, 0));
439 if !self.collapsed_sections.contains(&Section::ActiveCall) {
440 self.entries.extend(participant_entries);
441 }
442 }
443 }
444
445 self.entries
446 .push(ContactEntry::Header(Section::Channels, 0));
447
448 self.entries
449 .push(ContactEntry::Header(Section::Contacts, 0));
450
451 let mut request_entries = Vec::new();
452 let incoming = user_store.incoming_contact_requests();
453 if !incoming.is_empty() {
454 self.match_candidates.clear();
455 self.match_candidates
456 .extend(
457 incoming
458 .iter()
459 .enumerate()
460 .map(|(ix, user)| StringMatchCandidate {
461 id: ix,
462 string: user.github_login.clone(),
463 char_bag: user.github_login.chars().collect(),
464 }),
465 );
466 let matches = executor.block(match_strings(
467 &self.match_candidates,
468 &query,
469 true,
470 usize::MAX,
471 &Default::default(),
472 executor.clone(),
473 ));
474 request_entries.extend(
475 matches
476 .iter()
477 .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
478 );
479 }
480
481 let outgoing = user_store.outgoing_contact_requests();
482 if !outgoing.is_empty() {
483 self.match_candidates.clear();
484 self.match_candidates
485 .extend(
486 outgoing
487 .iter()
488 .enumerate()
489 .map(|(ix, user)| StringMatchCandidate {
490 id: ix,
491 string: user.github_login.clone(),
492 char_bag: user.github_login.chars().collect(),
493 }),
494 );
495 let matches = executor.block(match_strings(
496 &self.match_candidates,
497 &query,
498 true,
499 usize::MAX,
500 &Default::default(),
501 executor.clone(),
502 ));
503 request_entries.extend(
504 matches
505 .iter()
506 .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
507 );
508 }
509
510 if !request_entries.is_empty() {
511 self.entries
512 .push(ContactEntry::Header(Section::Requests, 1));
513 if !self.collapsed_sections.contains(&Section::Requests) {
514 self.entries.append(&mut request_entries);
515 }
516 }
517
518 let contacts = user_store.contacts();
519 if !contacts.is_empty() {
520 self.match_candidates.clear();
521 self.match_candidates
522 .extend(
523 contacts
524 .iter()
525 .enumerate()
526 .map(|(ix, contact)| StringMatchCandidate {
527 id: ix,
528 string: contact.user.github_login.clone(),
529 char_bag: contact.user.github_login.chars().collect(),
530 }),
531 );
532
533 let matches = executor.block(match_strings(
534 &self.match_candidates,
535 &query,
536 true,
537 usize::MAX,
538 &Default::default(),
539 executor.clone(),
540 ));
541
542 let (mut online_contacts, offline_contacts) = matches
543 .iter()
544 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
545 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
546 let room = room.read(cx);
547 online_contacts.retain(|contact| {
548 let contact = &contacts[contact.candidate_id];
549 !room.contains_participant(contact.user.id)
550 });
551 }
552
553 for (matches, section) in [
554 (online_contacts, Section::Online),
555 (offline_contacts, Section::Offline),
556 ] {
557 if !matches.is_empty() {
558 self.entries.push(ContactEntry::Header(section, 1));
559 if !self.collapsed_sections.contains(§ion) {
560 let active_call = &ActiveCall::global(cx).read(cx);
561 for mat in matches {
562 let contact = &contacts[mat.candidate_id];
563 self.entries.push(ContactEntry::Contact {
564 contact: contact.clone(),
565 calling: active_call.pending_invites().contains(&contact.user.id),
566 });
567 }
568 }
569 }
570 }
571 }
572
573 if let Some(prev_selected_entry) = prev_selected_entry {
574 self.selection.take();
575 for (ix, entry) in self.entries.iter().enumerate() {
576 if *entry == prev_selected_entry {
577 self.selection = Some(ix);
578 break;
579 }
580 }
581 }
582
583 let old_scroll_top = self.list_state.logical_scroll_top();
584 self.list_state.reset(self.entries.len());
585
586 // Attempt to maintain the same scroll position.
587 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
588 let new_scroll_top = self
589 .entries
590 .iter()
591 .position(|entry| entry == old_top_entry)
592 .map(|item_ix| ListOffset {
593 item_ix,
594 offset_in_item: old_scroll_top.offset_in_item,
595 })
596 .or_else(|| {
597 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
598 let item_ix = self
599 .entries
600 .iter()
601 .position(|entry| entry == entry_after_old_top)?;
602 Some(ListOffset {
603 item_ix,
604 offset_in_item: 0.,
605 })
606 })
607 .or_else(|| {
608 let entry_before_old_top =
609 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
610 let item_ix = self
611 .entries
612 .iter()
613 .position(|entry| entry == entry_before_old_top)?;
614 Some(ListOffset {
615 item_ix,
616 offset_in_item: 0.,
617 })
618 });
619
620 self.list_state
621 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
622 }
623
624 cx.notify();
625 }
626
627 fn render_call_participant(
628 user: &User,
629 is_pending: bool,
630 is_selected: bool,
631 theme: &theme::CollabPanel,
632 ) -> AnyElement<Self> {
633 Flex::row()
634 .with_children(user.avatar.clone().map(|avatar| {
635 Image::from_data(avatar)
636 .with_style(theme.contact_avatar)
637 .aligned()
638 .left()
639 }))
640 .with_child(
641 Label::new(
642 user.github_login.clone(),
643 theme.contact_username.text.clone(),
644 )
645 .contained()
646 .with_style(theme.contact_username.container)
647 .aligned()
648 .left()
649 .flex(1., true),
650 )
651 .with_children(if is_pending {
652 Some(
653 Label::new("Calling", theme.calling_indicator.text.clone())
654 .contained()
655 .with_style(theme.calling_indicator.container)
656 .aligned(),
657 )
658 } else {
659 None
660 })
661 .constrained()
662 .with_height(theme.row_height)
663 .contained()
664 .with_style(
665 *theme
666 .contact_row
667 .in_state(is_selected)
668 .style_for(&mut Default::default()),
669 )
670 .into_any()
671 }
672
673 fn render_participant_project(
674 project_id: u64,
675 worktree_root_names: &[String],
676 host_user_id: u64,
677 is_current: bool,
678 is_last: bool,
679 is_selected: bool,
680 theme: &theme::CollabPanel,
681 cx: &mut ViewContext<Self>,
682 ) -> AnyElement<Self> {
683 enum JoinProject {}
684
685 let font_cache = cx.font_cache();
686 let host_avatar_height = theme
687 .contact_avatar
688 .width
689 .or(theme.contact_avatar.height)
690 .unwrap_or(0.);
691 let row = &theme.project_row.inactive_state().default;
692 let tree_branch = theme.tree_branch;
693 let line_height = row.name.text.line_height(font_cache);
694 let cap_height = row.name.text.cap_height(font_cache);
695 let baseline_offset =
696 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
697 let project_name = if worktree_root_names.is_empty() {
698 "untitled".to_string()
699 } else {
700 worktree_root_names.join(", ")
701 };
702
703 MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
704 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
705 let row = theme
706 .project_row
707 .in_state(is_selected)
708 .style_for(mouse_state);
709
710 Flex::row()
711 .with_child(
712 Stack::new()
713 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
714 let start_x =
715 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
716 let end_x = bounds.max_x();
717 let start_y = bounds.min_y();
718 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
719
720 scene.push_quad(gpui::Quad {
721 bounds: RectF::from_points(
722 vec2f(start_x, start_y),
723 vec2f(
724 start_x + tree_branch.width,
725 if is_last { end_y } else { bounds.max_y() },
726 ),
727 ),
728 background: Some(tree_branch.color),
729 border: gpui::Border::default(),
730 corner_radius: 0.,
731 });
732 scene.push_quad(gpui::Quad {
733 bounds: RectF::from_points(
734 vec2f(start_x, end_y),
735 vec2f(end_x, end_y + tree_branch.width),
736 ),
737 background: Some(tree_branch.color),
738 border: gpui::Border::default(),
739 corner_radius: 0.,
740 });
741 }))
742 .constrained()
743 .with_width(host_avatar_height),
744 )
745 .with_child(
746 Label::new(project_name, row.name.text.clone())
747 .aligned()
748 .left()
749 .contained()
750 .with_style(row.name.container)
751 .flex(1., false),
752 )
753 .constrained()
754 .with_height(theme.row_height)
755 .contained()
756 .with_style(row.container)
757 })
758 .with_cursor_style(if !is_current {
759 CursorStyle::PointingHand
760 } else {
761 CursorStyle::Arrow
762 })
763 .on_click(MouseButton::Left, move |_, this, cx| {
764 if !is_current {
765 if let Some(workspace) = this.workspace.upgrade(cx) {
766 let app_state = workspace.read(cx).app_state().clone();
767 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
768 .detach_and_log_err(cx);
769 }
770 }
771 })
772 .into_any()
773 }
774
775 fn render_participant_screen(
776 peer_id: PeerId,
777 is_last: bool,
778 is_selected: bool,
779 theme: &theme::CollabPanel,
780 cx: &mut ViewContext<Self>,
781 ) -> AnyElement<Self> {
782 enum OpenSharedScreen {}
783
784 let font_cache = cx.font_cache();
785 let host_avatar_height = theme
786 .contact_avatar
787 .width
788 .or(theme.contact_avatar.height)
789 .unwrap_or(0.);
790 let row = &theme.project_row.inactive_state().default;
791 let tree_branch = theme.tree_branch;
792 let line_height = row.name.text.line_height(font_cache);
793 let cap_height = row.name.text.cap_height(font_cache);
794 let baseline_offset =
795 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
796
797 MouseEventHandler::<OpenSharedScreen, Self>::new(
798 peer_id.as_u64() as usize,
799 cx,
800 |mouse_state, _| {
801 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
802 let row = theme
803 .project_row
804 .in_state(is_selected)
805 .style_for(mouse_state);
806
807 Flex::row()
808 .with_child(
809 Stack::new()
810 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
811 let start_x = bounds.min_x() + (bounds.width() / 2.)
812 - (tree_branch.width / 2.);
813 let end_x = bounds.max_x();
814 let start_y = bounds.min_y();
815 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
816
817 scene.push_quad(gpui::Quad {
818 bounds: RectF::from_points(
819 vec2f(start_x, start_y),
820 vec2f(
821 start_x + tree_branch.width,
822 if is_last { end_y } else { bounds.max_y() },
823 ),
824 ),
825 background: Some(tree_branch.color),
826 border: gpui::Border::default(),
827 corner_radius: 0.,
828 });
829 scene.push_quad(gpui::Quad {
830 bounds: RectF::from_points(
831 vec2f(start_x, end_y),
832 vec2f(end_x, end_y + tree_branch.width),
833 ),
834 background: Some(tree_branch.color),
835 border: gpui::Border::default(),
836 corner_radius: 0.,
837 });
838 }))
839 .constrained()
840 .with_width(host_avatar_height),
841 )
842 .with_child(
843 Svg::new("icons/disable_screen_sharing_12.svg")
844 .with_color(row.icon.color)
845 .constrained()
846 .with_width(row.icon.width)
847 .aligned()
848 .left()
849 .contained()
850 .with_style(row.icon.container),
851 )
852 .with_child(
853 Label::new("Screen", row.name.text.clone())
854 .aligned()
855 .left()
856 .contained()
857 .with_style(row.name.container)
858 .flex(1., false),
859 )
860 .constrained()
861 .with_height(theme.row_height)
862 .contained()
863 .with_style(row.container)
864 },
865 )
866 .with_cursor_style(CursorStyle::PointingHand)
867 .on_click(MouseButton::Left, move |_, this, cx| {
868 if let Some(workspace) = this.workspace.upgrade(cx) {
869 workspace.update(cx, |workspace, cx| {
870 workspace.open_shared_screen(peer_id, cx)
871 });
872 }
873 })
874 .into_any()
875 }
876
877 fn render_header(
878 section: Section,
879 theme: &theme::Theme,
880 depth: usize,
881 is_selected: bool,
882 is_collapsed: bool,
883 cx: &mut ViewContext<Self>,
884 ) -> AnyElement<Self> {
885 enum Header {}
886 enum LeaveCallContactList {}
887 enum AddChannel {}
888
889 let tooltip_style = &theme.tooltip;
890 let text = match section {
891 Section::ActiveCall => "Current Call",
892 Section::Requests => "Requests",
893 Section::Contacts => "Contacts",
894 Section::Channels => "Channels",
895 Section::Online => "Online",
896 Section::Offline => "Offline",
897 };
898
899 enum AddContact {}
900 let button = match section {
901 Section::ActiveCall => Some(
902 MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
903 render_icon_button(
904 &theme.collab_panel.leave_call_button,
905 "icons/radix/exit.svg",
906 )
907 })
908 .with_cursor_style(CursorStyle::PointingHand)
909 .on_click(MouseButton::Left, |_, _, cx| {
910 ActiveCall::global(cx)
911 .update(cx, |call, cx| call.hang_up(cx))
912 .detach_and_log_err(cx);
913 })
914 .with_tooltip::<AddContact>(
915 0,
916 "Leave call".into(),
917 None,
918 tooltip_style.clone(),
919 cx,
920 ),
921 ),
922 Section::Contacts => Some(
923 MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |_, _| {
924 render_icon_button(
925 &theme.collab_panel.add_contact_button,
926 "icons/user_plus_16.svg",
927 )
928 })
929 .with_cursor_style(CursorStyle::PointingHand)
930 .on_click(MouseButton::Left, |_, this, cx| {
931 this.toggle_contact_finder(cx);
932 })
933 .with_tooltip::<LeaveCallContactList>(
934 0,
935 "Search for new contact".into(),
936 None,
937 tooltip_style.clone(),
938 cx,
939 ),
940 ),
941 Section::Channels => Some(
942 MouseEventHandler::<AddChannel, Self>::new(0, cx, |_, _| {
943 render_icon_button(&theme.collab_panel.add_contact_button, "icons/plus_16.svg")
944 })
945 .with_cursor_style(CursorStyle::PointingHand)
946 .on_click(MouseButton::Left, |_, this, cx| {
947 this.toggle_channel_finder(cx);
948 })
949 .with_tooltip::<AddChannel>(
950 0,
951 "Add or join a channel".into(),
952 None,
953 tooltip_style.clone(),
954 cx,
955 ),
956 ),
957 _ => None,
958 };
959
960 let can_collapse = depth > 0;
961 let icon_size = (&theme.collab_panel).section_icon_size;
962 MouseEventHandler::<Header, Self>::new(section as usize, cx, |state, _| {
963 let header_style = if can_collapse {
964 theme
965 .collab_panel
966 .subheader_row
967 .in_state(is_selected)
968 .style_for(state)
969 } else {
970 &theme.collab_panel.header_row
971 };
972
973 Flex::row()
974 .with_children(if can_collapse {
975 Some(
976 Svg::new(if is_collapsed {
977 "icons/chevron_right_8.svg"
978 } else {
979 "icons/chevron_down_8.svg"
980 })
981 .with_color(header_style.text.color)
982 .constrained()
983 .with_max_width(icon_size)
984 .with_max_height(icon_size)
985 .aligned()
986 .constrained()
987 .with_width(icon_size)
988 .contained()
989 .with_margin_right(
990 theme.collab_panel.contact_username.container.margin.left,
991 ),
992 )
993 } else {
994 None
995 })
996 .with_child(
997 Label::new(text, header_style.text.clone())
998 .aligned()
999 .left()
1000 .flex(1., true),
1001 )
1002 .with_children(button.map(|button| button.aligned().right()))
1003 .constrained()
1004 .with_height(theme.collab_panel.row_height)
1005 .contained()
1006 .with_style(header_style.container)
1007 })
1008 .with_cursor_style(CursorStyle::PointingHand)
1009 .on_click(MouseButton::Left, move |_, this, cx| {
1010 if can_collapse {
1011 this.toggle_expanded(section, cx);
1012 }
1013 })
1014 .into_any()
1015 }
1016
1017 fn render_contact(
1018 contact: &Contact,
1019 calling: bool,
1020 project: &ModelHandle<Project>,
1021 theme: &theme::CollabPanel,
1022 is_selected: bool,
1023 cx: &mut ViewContext<Self>,
1024 ) -> AnyElement<Self> {
1025 let online = contact.online;
1026 let busy = contact.busy || calling;
1027 let user_id = contact.user.id;
1028 let github_login = contact.user.github_login.clone();
1029 let initial_project = project.clone();
1030 let mut event_handler =
1031 MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |state, cx| {
1032 Flex::row()
1033 .with_children(contact.user.avatar.clone().map(|avatar| {
1034 let status_badge = if contact.online {
1035 Some(
1036 Empty::new()
1037 .collapsed()
1038 .contained()
1039 .with_style(if busy {
1040 theme.contact_status_busy
1041 } else {
1042 theme.contact_status_free
1043 })
1044 .aligned(),
1045 )
1046 } else {
1047 None
1048 };
1049 Stack::new()
1050 .with_child(
1051 Image::from_data(avatar)
1052 .with_style(theme.contact_avatar)
1053 .aligned()
1054 .left(),
1055 )
1056 .with_children(status_badge)
1057 }))
1058 .with_child(
1059 Label::new(
1060 contact.user.github_login.clone(),
1061 theme.contact_username.text.clone(),
1062 )
1063 .contained()
1064 .with_style(theme.contact_username.container)
1065 .aligned()
1066 .left()
1067 .flex(1., true),
1068 )
1069 .with_child(
1070 MouseEventHandler::<Cancel, Self>::new(
1071 contact.user.id as usize,
1072 cx,
1073 |mouse_state, _| {
1074 let button_style = theme.contact_button.style_for(mouse_state);
1075 render_icon_button(button_style, "icons/x_mark_8.svg")
1076 .aligned()
1077 .flex_float()
1078 },
1079 )
1080 .with_padding(Padding::uniform(2.))
1081 .with_cursor_style(CursorStyle::PointingHand)
1082 .on_click(MouseButton::Left, move |_, this, cx| {
1083 this.remove_contact(user_id, &github_login, cx);
1084 })
1085 .flex_float(),
1086 )
1087 .with_children(if calling {
1088 Some(
1089 Label::new("Calling", theme.calling_indicator.text.clone())
1090 .contained()
1091 .with_style(theme.calling_indicator.container)
1092 .aligned(),
1093 )
1094 } else {
1095 None
1096 })
1097 .constrained()
1098 .with_height(theme.row_height)
1099 .contained()
1100 .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
1101 })
1102 .on_click(MouseButton::Left, move |_, this, cx| {
1103 if online && !busy {
1104 this.call(user_id, Some(initial_project.clone()), cx);
1105 }
1106 });
1107
1108 if online {
1109 event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1110 }
1111
1112 event_handler.into_any()
1113 }
1114
1115 fn render_contact_request(
1116 user: Arc<User>,
1117 user_store: ModelHandle<UserStore>,
1118 theme: &theme::CollabPanel,
1119 is_incoming: bool,
1120 is_selected: bool,
1121 cx: &mut ViewContext<Self>,
1122 ) -> AnyElement<Self> {
1123 enum Decline {}
1124 enum Accept {}
1125 enum Cancel {}
1126
1127 let mut row = Flex::row()
1128 .with_children(user.avatar.clone().map(|avatar| {
1129 Image::from_data(avatar)
1130 .with_style(theme.contact_avatar)
1131 .aligned()
1132 .left()
1133 }))
1134 .with_child(
1135 Label::new(
1136 user.github_login.clone(),
1137 theme.contact_username.text.clone(),
1138 )
1139 .contained()
1140 .with_style(theme.contact_username.container)
1141 .aligned()
1142 .left()
1143 .flex(1., true),
1144 );
1145
1146 let user_id = user.id;
1147 let github_login = user.github_login.clone();
1148 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1149 let button_spacing = theme.contact_button_spacing;
1150
1151 if is_incoming {
1152 row.add_child(
1153 MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
1154 let button_style = if is_contact_request_pending {
1155 &theme.disabled_button
1156 } else {
1157 theme.contact_button.style_for(mouse_state)
1158 };
1159 render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1160 })
1161 .with_cursor_style(CursorStyle::PointingHand)
1162 .on_click(MouseButton::Left, move |_, this, cx| {
1163 this.respond_to_contact_request(user_id, false, cx);
1164 })
1165 .contained()
1166 .with_margin_right(button_spacing),
1167 );
1168
1169 row.add_child(
1170 MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
1171 let button_style = if is_contact_request_pending {
1172 &theme.disabled_button
1173 } else {
1174 theme.contact_button.style_for(mouse_state)
1175 };
1176 render_icon_button(button_style, "icons/check_8.svg")
1177 .aligned()
1178 .flex_float()
1179 })
1180 .with_cursor_style(CursorStyle::PointingHand)
1181 .on_click(MouseButton::Left, move |_, this, cx| {
1182 this.respond_to_contact_request(user_id, true, cx);
1183 }),
1184 );
1185 } else {
1186 row.add_child(
1187 MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
1188 let button_style = if is_contact_request_pending {
1189 &theme.disabled_button
1190 } else {
1191 theme.contact_button.style_for(mouse_state)
1192 };
1193 render_icon_button(button_style, "icons/x_mark_8.svg")
1194 .aligned()
1195 .flex_float()
1196 })
1197 .with_padding(Padding::uniform(2.))
1198 .with_cursor_style(CursorStyle::PointingHand)
1199 .on_click(MouseButton::Left, move |_, this, cx| {
1200 this.remove_contact(user_id, &github_login, cx);
1201 })
1202 .flex_float(),
1203 );
1204 }
1205
1206 row.constrained()
1207 .with_height(theme.row_height)
1208 .contained()
1209 .with_style(
1210 *theme
1211 .contact_row
1212 .in_state(is_selected)
1213 .style_for(&mut Default::default()),
1214 )
1215 .into_any()
1216 }
1217
1218 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1219 let did_clear = self.filter_editor.update(cx, |editor, cx| {
1220 if editor.buffer().read(cx).len(cx) > 0 {
1221 editor.set_text("", cx);
1222 true
1223 } else {
1224 false
1225 }
1226 });
1227
1228 if !did_clear {
1229 cx.emit(Event::Dismissed);
1230 }
1231 }
1232
1233 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1234 let mut ix = self.selection.map_or(0, |ix| ix + 1);
1235 while let Some(entry) = self.entries.get(ix) {
1236 if entry.is_selectable() {
1237 self.selection = Some(ix);
1238 break;
1239 }
1240 ix += 1;
1241 }
1242
1243 self.list_state.reset(self.entries.len());
1244 if let Some(ix) = self.selection {
1245 self.list_state.scroll_to(ListOffset {
1246 item_ix: ix,
1247 offset_in_item: 0.,
1248 });
1249 }
1250 cx.notify();
1251 }
1252
1253 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1254 if let Some(mut ix) = self.selection.take() {
1255 while ix > 0 {
1256 ix -= 1;
1257 if let Some(entry) = self.entries.get(ix) {
1258 if entry.is_selectable() {
1259 self.selection = Some(ix);
1260 break;
1261 }
1262 }
1263 }
1264 }
1265
1266 self.list_state.reset(self.entries.len());
1267 if let Some(ix) = self.selection {
1268 self.list_state.scroll_to(ListOffset {
1269 item_ix: ix,
1270 offset_in_item: 0.,
1271 });
1272 }
1273 cx.notify();
1274 }
1275
1276 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1277 if let Some(selection) = self.selection {
1278 if let Some(entry) = self.entries.get(selection) {
1279 match entry {
1280 ContactEntry::Header(section, _) => {
1281 self.toggle_expanded(*section, cx);
1282 }
1283 ContactEntry::Contact { contact, calling } => {
1284 if contact.online && !contact.busy && !calling {
1285 self.call(contact.user.id, Some(self.project.clone()), cx);
1286 }
1287 }
1288 ContactEntry::ParticipantProject {
1289 project_id,
1290 host_user_id,
1291 ..
1292 } => {
1293 if let Some(workspace) = self.workspace.upgrade(cx) {
1294 let app_state = workspace.read(cx).app_state().clone();
1295 workspace::join_remote_project(
1296 *project_id,
1297 *host_user_id,
1298 app_state,
1299 cx,
1300 )
1301 .detach_and_log_err(cx);
1302 }
1303 }
1304 ContactEntry::ParticipantScreen { peer_id, .. } => {
1305 if let Some(workspace) = self.workspace.upgrade(cx) {
1306 workspace.update(cx, |workspace, cx| {
1307 workspace.open_shared_screen(*peer_id, cx)
1308 });
1309 }
1310 }
1311 _ => {}
1312 }
1313 }
1314 }
1315 }
1316
1317 fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1318 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1319 self.collapsed_sections.remove(ix);
1320 } else {
1321 self.collapsed_sections.push(section);
1322 }
1323 self.update_entries(cx);
1324 }
1325
1326 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1327 if let Some(workspace) = self.workspace.upgrade(cx) {
1328 workspace.update(cx, |workspace, cx| {
1329 workspace.toggle_modal(cx, |_, cx| {
1330 cx.add_view(|cx| {
1331 let finder = build_contact_finder(self.user_store.clone(), cx);
1332 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1333 finder
1334 })
1335 });
1336 });
1337 }
1338 }
1339
1340 fn toggle_channel_finder(&mut self, cx: &mut ViewContext<Self>) {
1341 if let Some(workspace) = self.workspace.upgrade(cx) {
1342 workspace.update(cx, |workspace, cx| {
1343 workspace.toggle_modal(cx, |_, cx| cx.add_view(|cx| ChannelModal::new(cx)));
1344 });
1345 }
1346 }
1347
1348 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1349 let user_store = self.user_store.clone();
1350 let prompt_message = format!(
1351 "Are you sure you want to remove \"{}\" from your contacts?",
1352 github_login
1353 );
1354 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1355 let window_id = cx.window_id();
1356 cx.spawn(|_, mut cx| async move {
1357 if answer.next().await == Some(0) {
1358 if let Err(e) = user_store
1359 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
1360 .await
1361 {
1362 cx.prompt(
1363 window_id,
1364 PromptLevel::Info,
1365 &format!("Failed to remove contact: {}", e),
1366 &["Ok"],
1367 );
1368 }
1369 }
1370 })
1371 .detach();
1372 }
1373
1374 fn respond_to_contact_request(
1375 &mut self,
1376 user_id: u64,
1377 accept: bool,
1378 cx: &mut ViewContext<Self>,
1379 ) {
1380 self.user_store
1381 .update(cx, |store, cx| {
1382 store.respond_to_contact_request(user_id, accept, cx)
1383 })
1384 .detach();
1385 }
1386
1387 fn call(
1388 &mut self,
1389 recipient_user_id: u64,
1390 initial_project: Option<ModelHandle<Project>>,
1391 cx: &mut ViewContext<Self>,
1392 ) {
1393 ActiveCall::global(cx)
1394 .update(cx, |call, cx| {
1395 call.invite(recipient_user_id, initial_project, cx)
1396 })
1397 .detach_and_log_err(cx);
1398 }
1399}
1400
1401impl View for CollabPanel {
1402 fn ui_name() -> &'static str {
1403 "CollabPanel"
1404 }
1405
1406 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1407 if !self.has_focus {
1408 self.has_focus = true;
1409 cx.emit(Event::Focus);
1410 }
1411 }
1412
1413 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1414 self.has_focus = false;
1415 }
1416
1417 fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
1418 let theme = &theme::current(cx).collab_panel;
1419
1420 enum PanelFocus {}
1421 MouseEventHandler::<PanelFocus, _>::new(0, cx, |_, cx| {
1422 Stack::new()
1423 .with_child(
1424 Flex::column()
1425 .with_child(
1426 Flex::row()
1427 .with_child(
1428 ChildView::new(&self.filter_editor, cx)
1429 .contained()
1430 .with_style(theme.user_query_editor.container)
1431 .flex(1.0, true),
1432 )
1433 .constrained()
1434 .with_width(self.size(cx)),
1435 )
1436 .with_child(
1437 List::new(self.list_state.clone())
1438 .constrained()
1439 .with_width(self.size(cx))
1440 .flex(1., true)
1441 .into_any(),
1442 )
1443 .contained()
1444 .with_style(theme.container)
1445 .constrained()
1446 .with_width(self.size(cx))
1447 .into_any(),
1448 )
1449 .with_child(ChildView::new(&self.context_menu, cx))
1450 .into_any()
1451 })
1452 .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
1453 .into_any_named("channels panel")
1454 }
1455}
1456
1457impl Panel for CollabPanel {
1458 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
1459 match settings::get::<ChannelsPanelSettings>(cx).dock {
1460 ChannelsPanelDockPosition::Left => DockPosition::Left,
1461 ChannelsPanelDockPosition::Right => DockPosition::Right,
1462 }
1463 }
1464
1465 fn position_is_valid(&self, position: DockPosition) -> bool {
1466 matches!(position, DockPosition::Left | DockPosition::Right)
1467 }
1468
1469 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1470 settings::update_settings_file::<ChannelsPanelSettings>(
1471 self.fs.clone(),
1472 cx,
1473 move |settings| {
1474 let dock = match position {
1475 DockPosition::Left | DockPosition::Bottom => ChannelsPanelDockPosition::Left,
1476 DockPosition::Right => ChannelsPanelDockPosition::Right,
1477 };
1478 settings.dock = Some(dock);
1479 },
1480 );
1481 }
1482
1483 fn size(&self, cx: &gpui::WindowContext) -> f32 {
1484 self.width
1485 .unwrap_or_else(|| settings::get::<ChannelsPanelSettings>(cx).default_width)
1486 }
1487
1488 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1489 self.width = Some(size);
1490 self.serialize(cx);
1491 cx.notify();
1492 }
1493
1494 fn icon_path(&self) -> &'static str {
1495 "icons/radix/person.svg"
1496 }
1497
1498 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
1499 ("Channels Panel".to_string(), Some(Box::new(ToggleFocus)))
1500 }
1501
1502 fn should_change_position_on_event(event: &Self::Event) -> bool {
1503 matches!(event, Event::DockPositionChanged)
1504 }
1505
1506 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
1507 self.has_focus
1508 }
1509
1510 fn is_focus_event(event: &Self::Event) -> bool {
1511 matches!(event, Event::Focus)
1512 }
1513}
1514
1515impl ContactEntry {
1516 fn is_selectable(&self) -> bool {
1517 if let ContactEntry::Header(_, 0) = self {
1518 false
1519 } else {
1520 true
1521 }
1522 }
1523}
1524
1525impl PartialEq for ContactEntry {
1526 fn eq(&self, other: &Self) -> bool {
1527 match self {
1528 ContactEntry::Header(section_1, depth_1) => {
1529 if let ContactEntry::Header(section_2, depth_2) = other {
1530 return section_1 == section_2 && depth_1 == depth_2;
1531 }
1532 }
1533 ContactEntry::CallParticipant { user: user_1, .. } => {
1534 if let ContactEntry::CallParticipant { user: user_2, .. } = other {
1535 return user_1.id == user_2.id;
1536 }
1537 }
1538 ContactEntry::ParticipantProject {
1539 project_id: project_id_1,
1540 ..
1541 } => {
1542 if let ContactEntry::ParticipantProject {
1543 project_id: project_id_2,
1544 ..
1545 } = other
1546 {
1547 return project_id_1 == project_id_2;
1548 }
1549 }
1550 ContactEntry::ParticipantScreen {
1551 peer_id: peer_id_1, ..
1552 } => {
1553 if let ContactEntry::ParticipantScreen {
1554 peer_id: peer_id_2, ..
1555 } = other
1556 {
1557 return peer_id_1 == peer_id_2;
1558 }
1559 }
1560 ContactEntry::IncomingRequest(user_1) => {
1561 if let ContactEntry::IncomingRequest(user_2) = other {
1562 return user_1.id == user_2.id;
1563 }
1564 }
1565 ContactEntry::OutgoingRequest(user_1) => {
1566 if let ContactEntry::OutgoingRequest(user_2) = other {
1567 return user_1.id == user_2.id;
1568 }
1569 }
1570 ContactEntry::Contact {
1571 contact: contact_1, ..
1572 } => {
1573 if let ContactEntry::Contact {
1574 contact: contact_2, ..
1575 } = other
1576 {
1577 return contact_1.user.id == contact_2.user.id;
1578 }
1579 }
1580 }
1581 false
1582 }
1583}
1584
1585fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
1586 Svg::new(svg_path)
1587 .with_color(style.color)
1588 .constrained()
1589 .with_width(style.icon_width)
1590 .aligned()
1591 .constrained()
1592 .with_width(style.button_width)
1593 .with_height(style.button_width)
1594 .contained()
1595 .with_style(style.container)
1596}