1mod channel_modal;
2mod contact_finder;
3mod panel_settings;
4
5use anyhow::Result;
6use call::ActiveCall;
7use client::{
8 proto::PeerId, Channel, ChannelEvent, ChannelId, ChannelStore, Client, Contact, User, UserStore,
9};
10use contact_finder::build_contact_finder;
11use context_menu::{ContextMenu, ContextMenuItem};
12use db::kvp::KEY_VALUE_STORE;
13use editor::{Cancel, Editor};
14use futures::StreamExt;
15use fuzzy::{match_strings, StringMatchCandidate};
16use gpui::{
17 actions,
18 elements::{
19 Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState,
20 MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, Stack, Svg,
21 },
22 geometry::{
23 rect::RectF,
24 vector::{vec2f, Vector2F},
25 },
26 impl_actions,
27 platform::{CursorStyle, MouseButton, PromptLevel},
28 serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle,
29 Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
30};
31use menu::{Confirm, SelectNext, SelectPrev};
32use panel_settings::{CollaborationPanelDockPosition, CollaborationPanelSettings};
33use project::{Fs, Project};
34use serde_derive::{Deserialize, Serialize};
35use settings::SettingsStore;
36use staff_mode::StaffMode;
37use std::{mem, sync::Arc};
38use theme::IconButton;
39use util::{ResultExt, TryFutureExt};
40use workspace::{
41 dock::{DockPosition, Panel},
42 item::ItemHandle,
43 Workspace,
44};
45
46use crate::face_pile::FacePile;
47use channel_modal::ChannelModal;
48
49#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
50struct RemoveChannel {
51 channel_id: u64,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55struct NewChannel {
56 channel_id: u64,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
60struct InviteMembers {
61 channel_id: u64,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65struct ManageMembers {
66 channel_id: u64,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
70struct RenameChannel {
71 channel_id: u64,
72}
73
74actions!(collab_panel, [ToggleFocus, Remove, Secondary]);
75
76impl_actions!(
77 collab_panel,
78 [
79 RemoveChannel,
80 NewChannel,
81 InviteMembers,
82 ManageMembers,
83 RenameChannel
84 ]
85);
86
87const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel";
88
89pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
90 settings::register::<panel_settings::CollaborationPanelSettings>(cx);
91 contact_finder::init(cx);
92 channel_modal::init(cx);
93
94 cx.add_action(CollabPanel::cancel);
95 cx.add_action(CollabPanel::select_next);
96 cx.add_action(CollabPanel::select_prev);
97 cx.add_action(CollabPanel::confirm);
98 cx.add_action(CollabPanel::remove);
99 cx.add_action(CollabPanel::remove_selected_channel);
100 cx.add_action(CollabPanel::show_inline_context_menu);
101 cx.add_action(CollabPanel::new_subchannel);
102 cx.add_action(CollabPanel::invite_members);
103 cx.add_action(CollabPanel::manage_members);
104 cx.add_action(CollabPanel::rename_selected_channel);
105 cx.add_action(CollabPanel::rename_channel);
106}
107
108#[derive(Debug)]
109pub enum ChannelEditingState {
110 Create {
111 parent_id: Option<u64>,
112 pending_name: Option<String>,
113 },
114 Rename {
115 channel_id: u64,
116 pending_name: Option<String>,
117 },
118}
119
120impl ChannelEditingState {
121 fn pending_name(&self) -> Option<&str> {
122 match self {
123 ChannelEditingState::Create { pending_name, .. } => pending_name.as_deref(),
124 ChannelEditingState::Rename { pending_name, .. } => pending_name.as_deref(),
125 }
126 }
127}
128
129pub struct CollabPanel {
130 width: Option<f32>,
131 fs: Arc<dyn Fs>,
132 has_focus: bool,
133 pending_serialization: Task<Option<()>>,
134 context_menu: ViewHandle<ContextMenu>,
135 filter_editor: ViewHandle<Editor>,
136 channel_name_editor: ViewHandle<Editor>,
137 channel_editing_state: Option<ChannelEditingState>,
138 entries: Vec<ListEntry>,
139 selection: Option<usize>,
140 user_store: ModelHandle<UserStore>,
141 client: Arc<Client>,
142 channel_store: ModelHandle<ChannelStore>,
143 project: ModelHandle<Project>,
144 match_candidates: Vec<StringMatchCandidate>,
145 list_state: ListState<Self>,
146 subscriptions: Vec<Subscription>,
147 collapsed_sections: Vec<Section>,
148 workspace: WeakViewHandle<Workspace>,
149 context_menu_on_selected: bool,
150}
151
152#[derive(Serialize, Deserialize)]
153struct SerializedChannelsPanel {
154 width: Option<f32>,
155}
156
157#[derive(Debug)]
158pub enum Event {
159 DockPositionChanged,
160 Focus,
161 Dismissed,
162}
163
164#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
165enum Section {
166 ActiveCall,
167 Channels,
168 ChannelInvites,
169 ContactRequests,
170 Contacts,
171 Online,
172 Offline,
173}
174
175#[derive(Clone, Debug)]
176enum ListEntry {
177 Header(Section, usize),
178 CallParticipant {
179 user: Arc<User>,
180 is_pending: bool,
181 },
182 ParticipantProject {
183 project_id: u64,
184 worktree_root_names: Vec<String>,
185 host_user_id: u64,
186 is_last: bool,
187 },
188 ParticipantScreen {
189 peer_id: PeerId,
190 is_last: bool,
191 },
192 IncomingRequest(Arc<User>),
193 OutgoingRequest(Arc<User>),
194 ChannelInvite(Arc<Channel>),
195 Channel(Arc<Channel>),
196 ChannelEditor {
197 depth: usize,
198 },
199 Contact {
200 contact: Arc<Contact>,
201 calling: bool,
202 },
203}
204
205impl Entity for CollabPanel {
206 type Event = Event;
207}
208
209impl CollabPanel {
210 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
211 cx.add_view::<Self, _>(|cx| {
212 let view_id = cx.view_id();
213
214 let filter_editor = cx.add_view(|cx| {
215 let mut editor = Editor::single_line(
216 Some(Arc::new(|theme| {
217 theme.collab_panel.user_query_editor.clone()
218 })),
219 cx,
220 );
221 editor.set_placeholder_text("Filter channels, contacts", cx);
222 editor
223 });
224
225 cx.subscribe(&filter_editor, |this, _, event, cx| {
226 if let editor::Event::BufferEdited = event {
227 let query = this.filter_editor.read(cx).text(cx);
228 if !query.is_empty() {
229 this.selection.take();
230 }
231 this.update_entries(true, cx);
232 if !query.is_empty() {
233 this.selection = this
234 .entries
235 .iter()
236 .position(|entry| !matches!(entry, ListEntry::Header(_, _)));
237 }
238 }
239 })
240 .detach();
241
242 let channel_name_editor = cx.add_view(|cx| {
243 Editor::single_line(
244 Some(Arc::new(|theme| {
245 theme.collab_panel.user_query_editor.clone()
246 })),
247 cx,
248 )
249 });
250
251 cx.subscribe(&channel_name_editor, |this, _, event, cx| {
252 if let editor::Event::Blurred = event {
253 if let Some(state) = &this.channel_editing_state {
254 if state.pending_name().is_some() {
255 return;
256 }
257 }
258 this.take_editing_state(cx);
259 this.update_entries(false, cx);
260 cx.notify();
261 }
262 })
263 .detach();
264
265 let list_state =
266 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
267 let theme = theme::current(cx).clone();
268 let is_selected = this.selection == Some(ix);
269 let current_project_id = this.project.read(cx).remote_id();
270
271 match &this.entries[ix] {
272 ListEntry::Header(section, depth) => {
273 let is_collapsed = this.collapsed_sections.contains(section);
274 this.render_header(
275 *section,
276 &theme,
277 *depth,
278 is_selected,
279 is_collapsed,
280 cx,
281 )
282 }
283 ListEntry::CallParticipant { user, is_pending } => {
284 Self::render_call_participant(
285 user,
286 *is_pending,
287 is_selected,
288 &theme.collab_panel,
289 )
290 }
291 ListEntry::ParticipantProject {
292 project_id,
293 worktree_root_names,
294 host_user_id,
295 is_last,
296 } => Self::render_participant_project(
297 *project_id,
298 worktree_root_names,
299 *host_user_id,
300 Some(*project_id) == current_project_id,
301 *is_last,
302 is_selected,
303 &theme.collab_panel,
304 cx,
305 ),
306 ListEntry::ParticipantScreen { peer_id, is_last } => {
307 Self::render_participant_screen(
308 *peer_id,
309 *is_last,
310 is_selected,
311 &theme.collab_panel,
312 cx,
313 )
314 }
315 ListEntry::Channel(channel) => {
316 let channel_row = this.render_channel(
317 &*channel,
318 &theme.collab_panel,
319 is_selected,
320 cx,
321 );
322
323 if is_selected && this.context_menu_on_selected {
324 Stack::new()
325 .with_child(channel_row)
326 .with_child(
327 ChildView::new(&this.context_menu, cx)
328 .aligned()
329 .bottom()
330 .right(),
331 )
332 .into_any()
333 } else {
334 return channel_row;
335 }
336 }
337 ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
338 channel.clone(),
339 this.channel_store.clone(),
340 &theme.collab_panel,
341 is_selected,
342 cx,
343 ),
344 ListEntry::IncomingRequest(user) => Self::render_contact_request(
345 user.clone(),
346 this.user_store.clone(),
347 &theme.collab_panel,
348 true,
349 is_selected,
350 cx,
351 ),
352 ListEntry::OutgoingRequest(user) => Self::render_contact_request(
353 user.clone(),
354 this.user_store.clone(),
355 &theme.collab_panel,
356 false,
357 is_selected,
358 cx,
359 ),
360 ListEntry::Contact { contact, calling } => Self::render_contact(
361 contact,
362 *calling,
363 &this.project,
364 &theme.collab_panel,
365 is_selected,
366 cx,
367 ),
368 ListEntry::ChannelEditor { depth } => {
369 this.render_channel_editor(&theme, *depth, cx)
370 }
371 }
372 });
373
374 let mut this = Self {
375 width: None,
376 has_focus: false,
377 fs: workspace.app_state().fs.clone(),
378 pending_serialization: Task::ready(None),
379 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
380 channel_name_editor,
381 filter_editor,
382 entries: Vec::default(),
383 channel_editing_state: None,
384 selection: None,
385 user_store: workspace.user_store().clone(),
386 channel_store: workspace.app_state().channel_store.clone(),
387 project: workspace.project().clone(),
388 subscriptions: Vec::default(),
389 match_candidates: Vec::default(),
390 collapsed_sections: Vec::default(),
391 workspace: workspace.weak_handle(),
392 client: workspace.app_state().client.clone(),
393 context_menu_on_selected: true,
394 list_state,
395 };
396
397 this.update_entries(false, cx);
398
399 // Update the dock position when the setting changes.
400 let mut old_dock_position = this.position(cx);
401 this.subscriptions
402 .push(
403 cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
404 let new_dock_position = this.position(cx);
405 if new_dock_position != old_dock_position {
406 old_dock_position = new_dock_position;
407 cx.emit(Event::DockPositionChanged);
408 }
409 cx.notify();
410 }),
411 );
412
413 let active_call = ActiveCall::global(cx);
414 this.subscriptions
415 .push(cx.observe(&this.user_store, |this, _, cx| {
416 this.update_entries(true, cx)
417 }));
418 this.subscriptions
419 .push(cx.observe(&this.channel_store, |this, _, cx| {
420 this.update_entries(true, cx)
421 }));
422 this.subscriptions
423 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
424 this.subscriptions.push(
425 cx.observe_global::<StaffMode, _>(move |this, cx| this.update_entries(true, cx)),
426 );
427 this.subscriptions.push(cx.subscribe(
428 &this.channel_store,
429 |this, _channel_store, e, cx| match e {
430 ChannelEvent::ChannelCreated(channel_id)
431 | ChannelEvent::ChannelRenamed(channel_id) => {
432 if this.take_editing_state(cx) {
433 this.update_entries(false, cx);
434 this.selection = this.entries.iter().position(|entry| {
435 if let ListEntry::Channel(channel) = entry {
436 channel.id == *channel_id
437 } else {
438 false
439 }
440 });
441 }
442 }
443 },
444 ));
445
446 this
447 })
448 }
449
450 pub fn load(
451 workspace: WeakViewHandle<Workspace>,
452 cx: AsyncAppContext,
453 ) -> Task<Result<ViewHandle<Self>>> {
454 cx.spawn(|mut cx| async move {
455 let serialized_panel = if let Some(panel) = cx
456 .background()
457 .spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) })
458 .await
459 .log_err()
460 .flatten()
461 {
462 Some(serde_json::from_str::<SerializedChannelsPanel>(&panel)?)
463 } else {
464 None
465 };
466
467 workspace.update(&mut cx, |workspace, cx| {
468 let panel = CollabPanel::new(workspace, cx);
469 if let Some(serialized_panel) = serialized_panel {
470 panel.update(cx, |panel, cx| {
471 panel.width = serialized_panel.width;
472 cx.notify();
473 });
474 }
475 panel
476 })
477 })
478 }
479
480 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
481 let width = self.width;
482 self.pending_serialization = cx.background().spawn(
483 async move {
484 KEY_VALUE_STORE
485 .write_kvp(
486 CHANNELS_PANEL_KEY.into(),
487 serde_json::to_string(&SerializedChannelsPanel { width })?,
488 )
489 .await?;
490 anyhow::Ok(())
491 }
492 .log_err(),
493 );
494 }
495
496 fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
497 let channel_store = self.channel_store.read(cx);
498 let user_store = self.user_store.read(cx);
499 let query = self.filter_editor.read(cx).text(cx);
500 let executor = cx.background().clone();
501
502 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
503 let old_entries = mem::take(&mut self.entries);
504
505 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
506 self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
507
508 if !self.collapsed_sections.contains(&Section::ActiveCall) {
509 let room = room.read(cx);
510
511 // Populate the active user.
512 if let Some(user) = user_store.current_user() {
513 self.match_candidates.clear();
514 self.match_candidates.push(StringMatchCandidate {
515 id: 0,
516 string: user.github_login.clone(),
517 char_bag: user.github_login.chars().collect(),
518 });
519 let matches = executor.block(match_strings(
520 &self.match_candidates,
521 &query,
522 true,
523 usize::MAX,
524 &Default::default(),
525 executor.clone(),
526 ));
527 if !matches.is_empty() {
528 let user_id = user.id;
529 self.entries.push(ListEntry::CallParticipant {
530 user,
531 is_pending: false,
532 });
533 let mut projects = room.local_participant().projects.iter().peekable();
534 while let Some(project) = projects.next() {
535 self.entries.push(ListEntry::ParticipantProject {
536 project_id: project.id,
537 worktree_root_names: project.worktree_root_names.clone(),
538 host_user_id: user_id,
539 is_last: projects.peek().is_none(),
540 });
541 }
542 }
543 }
544
545 // Populate remote participants.
546 self.match_candidates.clear();
547 self.match_candidates
548 .extend(room.remote_participants().iter().map(|(_, participant)| {
549 StringMatchCandidate {
550 id: participant.user.id as usize,
551 string: participant.user.github_login.clone(),
552 char_bag: participant.user.github_login.chars().collect(),
553 }
554 }));
555 let matches = executor.block(match_strings(
556 &self.match_candidates,
557 &query,
558 true,
559 usize::MAX,
560 &Default::default(),
561 executor.clone(),
562 ));
563 for mat in matches {
564 let user_id = mat.candidate_id as u64;
565 let participant = &room.remote_participants()[&user_id];
566 self.entries.push(ListEntry::CallParticipant {
567 user: participant.user.clone(),
568 is_pending: false,
569 });
570 let mut projects = participant.projects.iter().peekable();
571 while let Some(project) = projects.next() {
572 self.entries.push(ListEntry::ParticipantProject {
573 project_id: project.id,
574 worktree_root_names: project.worktree_root_names.clone(),
575 host_user_id: participant.user.id,
576 is_last: projects.peek().is_none()
577 && participant.video_tracks.is_empty(),
578 });
579 }
580 if !participant.video_tracks.is_empty() {
581 self.entries.push(ListEntry::ParticipantScreen {
582 peer_id: participant.peer_id,
583 is_last: true,
584 });
585 }
586 }
587
588 // Populate pending participants.
589 self.match_candidates.clear();
590 self.match_candidates
591 .extend(room.pending_participants().iter().enumerate().map(
592 |(id, participant)| StringMatchCandidate {
593 id,
594 string: participant.github_login.clone(),
595 char_bag: participant.github_login.chars().collect(),
596 },
597 ));
598 let matches = executor.block(match_strings(
599 &self.match_candidates,
600 &query,
601 true,
602 usize::MAX,
603 &Default::default(),
604 executor.clone(),
605 ));
606 self.entries
607 .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
608 user: room.pending_participants()[mat.candidate_id].clone(),
609 is_pending: true,
610 }));
611 }
612 }
613
614 let mut request_entries = Vec::new();
615 if self.include_channels_section(cx) {
616 self.entries.push(ListEntry::Header(Section::Channels, 0));
617
618 let channels = channel_store.channels();
619 if !(channels.is_empty() && self.channel_editing_state.is_none()) {
620 self.match_candidates.clear();
621 self.match_candidates
622 .extend(channels.iter().enumerate().map(|(ix, channel)| {
623 StringMatchCandidate {
624 id: ix,
625 string: channel.name.clone(),
626 char_bag: channel.name.chars().collect(),
627 }
628 }));
629 let matches = executor.block(match_strings(
630 &self.match_candidates,
631 &query,
632 true,
633 usize::MAX,
634 &Default::default(),
635 executor.clone(),
636 ));
637 if let Some(state) = &self.channel_editing_state {
638 if matches!(
639 state,
640 ChannelEditingState::Create {
641 parent_id: None,
642 ..
643 }
644 ) {
645 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
646 }
647 }
648 for mat in matches {
649 let channel = &channels[mat.candidate_id];
650
651 match &self.channel_editing_state {
652 Some(ChannelEditingState::Create { parent_id, .. })
653 if *parent_id == Some(channel.id) =>
654 {
655 self.entries.push(ListEntry::Channel(channel.clone()));
656 self.entries.push(ListEntry::ChannelEditor {
657 depth: channel.depth + 1,
658 });
659 }
660 Some(ChannelEditingState::Rename { channel_id, .. })
661 if *channel_id == channel.id =>
662 {
663 self.entries.push(ListEntry::ChannelEditor {
664 depth: channel.depth,
665 });
666 }
667 _ => {
668 self.entries.push(ListEntry::Channel(channel.clone()));
669 }
670 }
671 }
672 }
673
674 let channel_invites = channel_store.channel_invitations();
675 if !channel_invites.is_empty() {
676 self.match_candidates.clear();
677 self.match_candidates
678 .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
679 StringMatchCandidate {
680 id: ix,
681 string: channel.name.clone(),
682 char_bag: channel.name.chars().collect(),
683 }
684 }));
685 let matches = executor.block(match_strings(
686 &self.match_candidates,
687 &query,
688 true,
689 usize::MAX,
690 &Default::default(),
691 executor.clone(),
692 ));
693 request_entries.extend(matches.iter().map(|mat| {
694 ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
695 }));
696
697 if !request_entries.is_empty() {
698 self.entries
699 .push(ListEntry::Header(Section::ChannelInvites, 1));
700 if !self.collapsed_sections.contains(&Section::ChannelInvites) {
701 self.entries.append(&mut request_entries);
702 }
703 }
704 }
705 }
706
707 self.entries.push(ListEntry::Header(Section::Contacts, 0));
708
709 request_entries.clear();
710 let incoming = user_store.incoming_contact_requests();
711 if !incoming.is_empty() {
712 self.match_candidates.clear();
713 self.match_candidates
714 .extend(
715 incoming
716 .iter()
717 .enumerate()
718 .map(|(ix, user)| StringMatchCandidate {
719 id: ix,
720 string: user.github_login.clone(),
721 char_bag: user.github_login.chars().collect(),
722 }),
723 );
724 let matches = executor.block(match_strings(
725 &self.match_candidates,
726 &query,
727 true,
728 usize::MAX,
729 &Default::default(),
730 executor.clone(),
731 ));
732 request_entries.extend(
733 matches
734 .iter()
735 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
736 );
737 }
738
739 let outgoing = user_store.outgoing_contact_requests();
740 if !outgoing.is_empty() {
741 self.match_candidates.clear();
742 self.match_candidates
743 .extend(
744 outgoing
745 .iter()
746 .enumerate()
747 .map(|(ix, user)| StringMatchCandidate {
748 id: ix,
749 string: user.github_login.clone(),
750 char_bag: user.github_login.chars().collect(),
751 }),
752 );
753 let matches = executor.block(match_strings(
754 &self.match_candidates,
755 &query,
756 true,
757 usize::MAX,
758 &Default::default(),
759 executor.clone(),
760 ));
761 request_entries.extend(
762 matches
763 .iter()
764 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
765 );
766 }
767
768 if !request_entries.is_empty() {
769 self.entries
770 .push(ListEntry::Header(Section::ContactRequests, 1));
771 if !self.collapsed_sections.contains(&Section::ContactRequests) {
772 self.entries.append(&mut request_entries);
773 }
774 }
775
776 let contacts = user_store.contacts();
777 if !contacts.is_empty() {
778 self.match_candidates.clear();
779 self.match_candidates
780 .extend(
781 contacts
782 .iter()
783 .enumerate()
784 .map(|(ix, contact)| StringMatchCandidate {
785 id: ix,
786 string: contact.user.github_login.clone(),
787 char_bag: contact.user.github_login.chars().collect(),
788 }),
789 );
790
791 let matches = executor.block(match_strings(
792 &self.match_candidates,
793 &query,
794 true,
795 usize::MAX,
796 &Default::default(),
797 executor.clone(),
798 ));
799
800 let (online_contacts, offline_contacts) = matches
801 .iter()
802 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
803
804 for (matches, section) in [
805 (online_contacts, Section::Online),
806 (offline_contacts, Section::Offline),
807 ] {
808 if !matches.is_empty() {
809 self.entries.push(ListEntry::Header(section, 1));
810 if !self.collapsed_sections.contains(§ion) {
811 let active_call = &ActiveCall::global(cx).read(cx);
812 for mat in matches {
813 let contact = &contacts[mat.candidate_id];
814 self.entries.push(ListEntry::Contact {
815 contact: contact.clone(),
816 calling: active_call.pending_invites().contains(&contact.user.id),
817 });
818 }
819 }
820 }
821 }
822 }
823
824 if select_same_item {
825 if let Some(prev_selected_entry) = prev_selected_entry {
826 self.selection.take();
827 for (ix, entry) in self.entries.iter().enumerate() {
828 if *entry == prev_selected_entry {
829 self.selection = Some(ix);
830 break;
831 }
832 }
833 }
834 } else {
835 self.selection = self.selection.and_then(|prev_selection| {
836 if self.entries.is_empty() {
837 None
838 } else {
839 Some(prev_selection.min(self.entries.len() - 1))
840 }
841 });
842 }
843
844 let old_scroll_top = self.list_state.logical_scroll_top();
845 self.list_state.reset(self.entries.len());
846
847 // Attempt to maintain the same scroll position.
848 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
849 let new_scroll_top = self
850 .entries
851 .iter()
852 .position(|entry| entry == old_top_entry)
853 .map(|item_ix| ListOffset {
854 item_ix,
855 offset_in_item: old_scroll_top.offset_in_item,
856 })
857 .or_else(|| {
858 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
859 let item_ix = self
860 .entries
861 .iter()
862 .position(|entry| entry == entry_after_old_top)?;
863 Some(ListOffset {
864 item_ix,
865 offset_in_item: 0.,
866 })
867 })
868 .or_else(|| {
869 let entry_before_old_top =
870 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
871 let item_ix = self
872 .entries
873 .iter()
874 .position(|entry| entry == entry_before_old_top)?;
875 Some(ListOffset {
876 item_ix,
877 offset_in_item: 0.,
878 })
879 });
880
881 self.list_state
882 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
883 }
884
885 cx.notify();
886 }
887
888 fn render_call_participant(
889 user: &User,
890 is_pending: bool,
891 is_selected: bool,
892 theme: &theme::CollabPanel,
893 ) -> AnyElement<Self> {
894 Flex::row()
895 .with_children(user.avatar.clone().map(|avatar| {
896 Image::from_data(avatar)
897 .with_style(theme.contact_avatar)
898 .aligned()
899 .left()
900 }))
901 .with_child(
902 Label::new(
903 user.github_login.clone(),
904 theme.contact_username.text.clone(),
905 )
906 .contained()
907 .with_style(theme.contact_username.container)
908 .aligned()
909 .left()
910 .flex(1., true),
911 )
912 .with_children(if is_pending {
913 Some(
914 Label::new("Calling", theme.calling_indicator.text.clone())
915 .contained()
916 .with_style(theme.calling_indicator.container)
917 .aligned(),
918 )
919 } else {
920 None
921 })
922 .constrained()
923 .with_height(theme.row_height)
924 .contained()
925 .with_style(
926 *theme
927 .contact_row
928 .in_state(is_selected)
929 .style_for(&mut Default::default()),
930 )
931 .into_any()
932 }
933
934 fn render_participant_project(
935 project_id: u64,
936 worktree_root_names: &[String],
937 host_user_id: u64,
938 is_current: bool,
939 is_last: bool,
940 is_selected: bool,
941 theme: &theme::CollabPanel,
942 cx: &mut ViewContext<Self>,
943 ) -> AnyElement<Self> {
944 enum JoinProject {}
945
946 let font_cache = cx.font_cache();
947 let host_avatar_height = theme
948 .contact_avatar
949 .width
950 .or(theme.contact_avatar.height)
951 .unwrap_or(0.);
952 let row = &theme.project_row.inactive_state().default;
953 let tree_branch = theme.tree_branch;
954 let line_height = row.name.text.line_height(font_cache);
955 let cap_height = row.name.text.cap_height(font_cache);
956 let baseline_offset =
957 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
958 let project_name = if worktree_root_names.is_empty() {
959 "untitled".to_string()
960 } else {
961 worktree_root_names.join(", ")
962 };
963
964 MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
965 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
966 let row = theme
967 .project_row
968 .in_state(is_selected)
969 .style_for(mouse_state);
970
971 Flex::row()
972 .with_child(
973 Stack::new()
974 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
975 let start_x =
976 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
977 let end_x = bounds.max_x();
978 let start_y = bounds.min_y();
979 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
980
981 scene.push_quad(gpui::Quad {
982 bounds: RectF::from_points(
983 vec2f(start_x, start_y),
984 vec2f(
985 start_x + tree_branch.width,
986 if is_last { end_y } else { bounds.max_y() },
987 ),
988 ),
989 background: Some(tree_branch.color),
990 border: gpui::Border::default(),
991 corner_radius: 0.,
992 });
993 scene.push_quad(gpui::Quad {
994 bounds: RectF::from_points(
995 vec2f(start_x, end_y),
996 vec2f(end_x, end_y + tree_branch.width),
997 ),
998 background: Some(tree_branch.color),
999 border: gpui::Border::default(),
1000 corner_radius: 0.,
1001 });
1002 }))
1003 .constrained()
1004 .with_width(host_avatar_height),
1005 )
1006 .with_child(
1007 Label::new(project_name, row.name.text.clone())
1008 .aligned()
1009 .left()
1010 .contained()
1011 .with_style(row.name.container)
1012 .flex(1., false),
1013 )
1014 .constrained()
1015 .with_height(theme.row_height)
1016 .contained()
1017 .with_style(row.container)
1018 })
1019 .with_cursor_style(if !is_current {
1020 CursorStyle::PointingHand
1021 } else {
1022 CursorStyle::Arrow
1023 })
1024 .on_click(MouseButton::Left, move |_, this, cx| {
1025 if !is_current {
1026 if let Some(workspace) = this.workspace.upgrade(cx) {
1027 let app_state = workspace.read(cx).app_state().clone();
1028 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
1029 .detach_and_log_err(cx);
1030 }
1031 }
1032 })
1033 .into_any()
1034 }
1035
1036 fn render_participant_screen(
1037 peer_id: PeerId,
1038 is_last: bool,
1039 is_selected: bool,
1040 theme: &theme::CollabPanel,
1041 cx: &mut ViewContext<Self>,
1042 ) -> AnyElement<Self> {
1043 enum OpenSharedScreen {}
1044
1045 let font_cache = cx.font_cache();
1046 let host_avatar_height = theme
1047 .contact_avatar
1048 .width
1049 .or(theme.contact_avatar.height)
1050 .unwrap_or(0.);
1051 let row = &theme.project_row.inactive_state().default;
1052 let tree_branch = theme.tree_branch;
1053 let line_height = row.name.text.line_height(font_cache);
1054 let cap_height = row.name.text.cap_height(font_cache);
1055 let baseline_offset =
1056 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
1057
1058 MouseEventHandler::<OpenSharedScreen, Self>::new(
1059 peer_id.as_u64() as usize,
1060 cx,
1061 |mouse_state, _| {
1062 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
1063 let row = theme
1064 .project_row
1065 .in_state(is_selected)
1066 .style_for(mouse_state);
1067
1068 Flex::row()
1069 .with_child(
1070 Stack::new()
1071 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
1072 let start_x = bounds.min_x() + (bounds.width() / 2.)
1073 - (tree_branch.width / 2.);
1074 let end_x = bounds.max_x();
1075 let start_y = bounds.min_y();
1076 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
1077
1078 scene.push_quad(gpui::Quad {
1079 bounds: RectF::from_points(
1080 vec2f(start_x, start_y),
1081 vec2f(
1082 start_x + tree_branch.width,
1083 if is_last { end_y } else { bounds.max_y() },
1084 ),
1085 ),
1086 background: Some(tree_branch.color),
1087 border: gpui::Border::default(),
1088 corner_radius: 0.,
1089 });
1090 scene.push_quad(gpui::Quad {
1091 bounds: RectF::from_points(
1092 vec2f(start_x, end_y),
1093 vec2f(end_x, end_y + tree_branch.width),
1094 ),
1095 background: Some(tree_branch.color),
1096 border: gpui::Border::default(),
1097 corner_radius: 0.,
1098 });
1099 }))
1100 .constrained()
1101 .with_width(host_avatar_height),
1102 )
1103 .with_child(
1104 Svg::new("icons/disable_screen_sharing_12.svg")
1105 .with_color(row.icon.color)
1106 .constrained()
1107 .with_width(row.icon.width)
1108 .aligned()
1109 .left()
1110 .contained()
1111 .with_style(row.icon.container),
1112 )
1113 .with_child(
1114 Label::new("Screen", row.name.text.clone())
1115 .aligned()
1116 .left()
1117 .contained()
1118 .with_style(row.name.container)
1119 .flex(1., false),
1120 )
1121 .constrained()
1122 .with_height(theme.row_height)
1123 .contained()
1124 .with_style(row.container)
1125 },
1126 )
1127 .with_cursor_style(CursorStyle::PointingHand)
1128 .on_click(MouseButton::Left, move |_, this, cx| {
1129 if let Some(workspace) = this.workspace.upgrade(cx) {
1130 workspace.update(cx, |workspace, cx| {
1131 workspace.open_shared_screen(peer_id, cx)
1132 });
1133 }
1134 })
1135 .into_any()
1136 }
1137
1138 fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
1139 if let Some(_) = self.channel_editing_state.take() {
1140 self.channel_name_editor.update(cx, |editor, cx| {
1141 editor.set_text("", cx);
1142 });
1143 true
1144 } else {
1145 false
1146 }
1147 }
1148
1149 fn render_header(
1150 &self,
1151 section: Section,
1152 theme: &theme::Theme,
1153 depth: usize,
1154 is_selected: bool,
1155 is_collapsed: bool,
1156 cx: &mut ViewContext<Self>,
1157 ) -> AnyElement<Self> {
1158 enum Header {}
1159 enum LeaveCallContactList {}
1160 enum AddChannel {}
1161
1162 let tooltip_style = &theme.tooltip;
1163 let text = match section {
1164 Section::ActiveCall => "Current Call",
1165 Section::ContactRequests => "Requests",
1166 Section::Contacts => "Contacts",
1167 Section::Channels => "Channels",
1168 Section::ChannelInvites => "Invites",
1169 Section::Online => "Online",
1170 Section::Offline => "Offline",
1171 };
1172
1173 enum AddContact {}
1174 let button = match section {
1175 Section::ActiveCall => Some(
1176 MouseEventHandler::<AddContact, Self>::new(0, cx, |state, _| {
1177 render_icon_button(
1178 theme
1179 .collab_panel
1180 .leave_call_button
1181 .style_for(is_selected, state),
1182 "icons/radix/exit.svg",
1183 )
1184 })
1185 .with_cursor_style(CursorStyle::PointingHand)
1186 .on_click(MouseButton::Left, |_, _, cx| {
1187 Self::leave_call(cx);
1188 })
1189 .with_tooltip::<AddContact>(
1190 0,
1191 "Leave call".into(),
1192 None,
1193 tooltip_style.clone(),
1194 cx,
1195 ),
1196 ),
1197 Section::Contacts => Some(
1198 MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |state, _| {
1199 render_icon_button(
1200 theme
1201 .collab_panel
1202 .add_contact_button
1203 .style_for(is_selected, state),
1204 "icons/plus_16.svg",
1205 )
1206 })
1207 .with_cursor_style(CursorStyle::PointingHand)
1208 .on_click(MouseButton::Left, |_, this, cx| {
1209 this.toggle_contact_finder(cx);
1210 })
1211 .with_tooltip::<LeaveCallContactList>(
1212 0,
1213 "Search for new contact".into(),
1214 None,
1215 tooltip_style.clone(),
1216 cx,
1217 ),
1218 ),
1219 Section::Channels => Some(
1220 MouseEventHandler::<AddChannel, Self>::new(0, cx, |state, _| {
1221 render_icon_button(
1222 theme
1223 .collab_panel
1224 .add_contact_button
1225 .style_for(is_selected, state),
1226 "icons/plus_16.svg",
1227 )
1228 })
1229 .with_cursor_style(CursorStyle::PointingHand)
1230 .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
1231 .with_tooltip::<AddChannel>(
1232 0,
1233 "Add or join a channel".into(),
1234 None,
1235 tooltip_style.clone(),
1236 cx,
1237 ),
1238 ),
1239 _ => None,
1240 };
1241
1242 let can_collapse = depth > 0;
1243 let icon_size = (&theme.collab_panel).section_icon_size;
1244 MouseEventHandler::<Header, Self>::new(section as usize, cx, |state, _| {
1245 let header_style = if can_collapse {
1246 theme
1247 .collab_panel
1248 .subheader_row
1249 .in_state(is_selected)
1250 .style_for(state)
1251 } else {
1252 &theme.collab_panel.header_row
1253 };
1254
1255 Flex::row()
1256 .with_children(if can_collapse {
1257 Some(
1258 Svg::new(if is_collapsed {
1259 "icons/chevron_right_8.svg"
1260 } else {
1261 "icons/chevron_down_8.svg"
1262 })
1263 .with_color(header_style.text.color)
1264 .constrained()
1265 .with_max_width(icon_size)
1266 .with_max_height(icon_size)
1267 .aligned()
1268 .constrained()
1269 .with_width(icon_size)
1270 .contained()
1271 .with_margin_right(
1272 theme.collab_panel.contact_username.container.margin.left,
1273 ),
1274 )
1275 } else {
1276 None
1277 })
1278 .with_child(
1279 Label::new(text, header_style.text.clone())
1280 .aligned()
1281 .left()
1282 .flex(1., true),
1283 )
1284 .with_children(button.map(|button| button.aligned().right()))
1285 .constrained()
1286 .with_height(theme.collab_panel.row_height)
1287 .contained()
1288 .with_style(header_style.container)
1289 })
1290 .with_cursor_style(CursorStyle::PointingHand)
1291 .on_click(MouseButton::Left, move |_, this, cx| {
1292 if can_collapse {
1293 this.toggle_expanded(section, cx);
1294 }
1295 })
1296 .into_any()
1297 }
1298
1299 fn render_contact(
1300 contact: &Contact,
1301 calling: bool,
1302 project: &ModelHandle<Project>,
1303 theme: &theme::CollabPanel,
1304 is_selected: bool,
1305 cx: &mut ViewContext<Self>,
1306 ) -> AnyElement<Self> {
1307 let online = contact.online;
1308 let busy = contact.busy || calling;
1309 let user_id = contact.user.id;
1310 let github_login = contact.user.github_login.clone();
1311 let initial_project = project.clone();
1312 let mut event_handler =
1313 MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |state, cx| {
1314 Flex::row()
1315 .with_children(contact.user.avatar.clone().map(|avatar| {
1316 let status_badge = if contact.online {
1317 Some(
1318 Empty::new()
1319 .collapsed()
1320 .contained()
1321 .with_style(if busy {
1322 theme.contact_status_busy
1323 } else {
1324 theme.contact_status_free
1325 })
1326 .aligned(),
1327 )
1328 } else {
1329 None
1330 };
1331 Stack::new()
1332 .with_child(
1333 Image::from_data(avatar)
1334 .with_style(theme.contact_avatar)
1335 .aligned()
1336 .left(),
1337 )
1338 .with_children(status_badge)
1339 }))
1340 .with_child(
1341 Label::new(
1342 contact.user.github_login.clone(),
1343 theme.contact_username.text.clone(),
1344 )
1345 .contained()
1346 .with_style(theme.contact_username.container)
1347 .aligned()
1348 .left()
1349 .flex(1., true),
1350 )
1351 .with_child(
1352 MouseEventHandler::<Cancel, Self>::new(
1353 contact.user.id as usize,
1354 cx,
1355 |mouse_state, _| {
1356 let button_style = theme.contact_button.style_for(mouse_state);
1357 render_icon_button(button_style, "icons/x_mark_8.svg")
1358 .aligned()
1359 .flex_float()
1360 },
1361 )
1362 .with_padding(Padding::uniform(2.))
1363 .with_cursor_style(CursorStyle::PointingHand)
1364 .on_click(MouseButton::Left, move |_, this, cx| {
1365 this.remove_contact(user_id, &github_login, cx);
1366 })
1367 .flex_float(),
1368 )
1369 .with_children(if calling {
1370 Some(
1371 Label::new("Calling", theme.calling_indicator.text.clone())
1372 .contained()
1373 .with_style(theme.calling_indicator.container)
1374 .aligned(),
1375 )
1376 } else {
1377 None
1378 })
1379 .constrained()
1380 .with_height(theme.row_height)
1381 .contained()
1382 .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
1383 })
1384 .on_click(MouseButton::Left, move |_, this, cx| {
1385 if online && !busy {
1386 this.call(user_id, Some(initial_project.clone()), cx);
1387 }
1388 });
1389
1390 if online {
1391 event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1392 }
1393
1394 event_handler.into_any()
1395 }
1396
1397 fn render_channel_editor(
1398 &self,
1399 theme: &theme::Theme,
1400 depth: usize,
1401 cx: &AppContext,
1402 ) -> AnyElement<Self> {
1403 Flex::row()
1404 .with_child(
1405 Svg::new("icons/channel_hash.svg")
1406 .with_color(theme.collab_panel.channel_hash.color)
1407 .constrained()
1408 .with_width(theme.collab_panel.channel_hash.width)
1409 .aligned()
1410 .left(),
1411 )
1412 .with_child(
1413 if let Some(pending_name) = self
1414 .channel_editing_state
1415 .as_ref()
1416 .and_then(|state| state.pending_name())
1417 {
1418 Label::new(
1419 pending_name.to_string(),
1420 theme.collab_panel.contact_username.text.clone(),
1421 )
1422 .contained()
1423 .with_style(theme.collab_panel.contact_username.container)
1424 .aligned()
1425 .left()
1426 .flex(1., true)
1427 .into_any()
1428 } else {
1429 ChildView::new(&self.channel_name_editor, cx)
1430 .aligned()
1431 .left()
1432 .contained()
1433 .with_style(theme.collab_panel.channel_editor)
1434 .flex(1.0, true)
1435 .into_any()
1436 },
1437 )
1438 .align_children_center()
1439 .constrained()
1440 .with_height(theme.collab_panel.row_height)
1441 .contained()
1442 .with_style(gpui::elements::ContainerStyle {
1443 background_color: Some(theme.editor.background),
1444 ..*theme.collab_panel.contact_row.default_style()
1445 })
1446 .with_padding_left(
1447 theme.collab_panel.contact_row.default_style().padding.left
1448 + theme.collab_panel.channel_indent * depth as f32,
1449 )
1450 .into_any()
1451 }
1452
1453 fn render_channel(
1454 &self,
1455 channel: &Channel,
1456 theme: &theme::CollabPanel,
1457 is_selected: bool,
1458 cx: &mut ViewContext<Self>,
1459 ) -> AnyElement<Self> {
1460 let channel_id = channel.id;
1461
1462 MouseEventHandler::<Channel, Self>::new(channel.id as usize, cx, |state, cx| {
1463 Flex::row()
1464 .with_child(
1465 Svg::new("icons/channel_hash.svg")
1466 .with_color(theme.channel_hash.color)
1467 .constrained()
1468 .with_width(theme.channel_hash.width)
1469 .aligned()
1470 .left(),
1471 )
1472 .with_child(
1473 Label::new(channel.name.clone(), theme.contact_username.text.clone())
1474 .contained()
1475 .with_style(theme.contact_username.container)
1476 .aligned()
1477 .left()
1478 .flex(1., true),
1479 )
1480 .with_child(
1481 FacePile::new(theme.face_overlap).with_children(
1482 self.channel_store
1483 .read(cx)
1484 .channel_participants(channel_id)
1485 .iter()
1486 .filter_map(|user| {
1487 Some(
1488 Image::from_data(user.avatar.clone()?)
1489 .with_style(theme.contact_avatar),
1490 )
1491 }),
1492 ),
1493 )
1494 .align_children_center()
1495 .constrained()
1496 .with_height(theme.row_height)
1497 .contained()
1498 .with_style(*theme.contact_row.style_for(is_selected, state))
1499 .with_padding_left(
1500 theme.contact_row.default_style().padding.left
1501 + theme.channel_indent * channel.depth as f32,
1502 )
1503 })
1504 .on_click(MouseButton::Left, move |_, this, cx| {
1505 this.join_channel(channel_id, cx);
1506 })
1507 .on_click(MouseButton::Right, move |e, this, cx| {
1508 this.deploy_channel_context_menu(Some(e.position), channel_id, cx);
1509 })
1510 .into_any()
1511 }
1512
1513 fn render_channel_invite(
1514 channel: Arc<Channel>,
1515 channel_store: ModelHandle<ChannelStore>,
1516 theme: &theme::CollabPanel,
1517 is_selected: bool,
1518 cx: &mut ViewContext<Self>,
1519 ) -> AnyElement<Self> {
1520 enum Decline {}
1521 enum Accept {}
1522
1523 let channel_id = channel.id;
1524 let is_invite_pending = channel_store
1525 .read(cx)
1526 .has_pending_channel_invite_response(&channel);
1527 let button_spacing = theme.contact_button_spacing;
1528
1529 Flex::row()
1530 .with_child(
1531 Svg::new("icons/channel_hash.svg")
1532 .with_color(theme.channel_hash.color)
1533 .constrained()
1534 .with_width(theme.channel_hash.width)
1535 .aligned()
1536 .left(),
1537 )
1538 .with_child(
1539 Label::new(channel.name.clone(), theme.contact_username.text.clone())
1540 .contained()
1541 .with_style(theme.contact_username.container)
1542 .aligned()
1543 .left()
1544 .flex(1., true),
1545 )
1546 .with_child(
1547 MouseEventHandler::<Decline, Self>::new(
1548 channel.id as usize,
1549 cx,
1550 |mouse_state, _| {
1551 let button_style = if is_invite_pending {
1552 &theme.disabled_button
1553 } else {
1554 theme.contact_button.style_for(mouse_state)
1555 };
1556 render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1557 },
1558 )
1559 .with_cursor_style(CursorStyle::PointingHand)
1560 .on_click(MouseButton::Left, move |_, this, cx| {
1561 this.respond_to_channel_invite(channel_id, false, cx);
1562 })
1563 .contained()
1564 .with_margin_right(button_spacing),
1565 )
1566 .with_child(
1567 MouseEventHandler::<Accept, Self>::new(
1568 channel.id as usize,
1569 cx,
1570 |mouse_state, _| {
1571 let button_style = if is_invite_pending {
1572 &theme.disabled_button
1573 } else {
1574 theme.contact_button.style_for(mouse_state)
1575 };
1576 render_icon_button(button_style, "icons/check_8.svg")
1577 .aligned()
1578 .flex_float()
1579 },
1580 )
1581 .with_cursor_style(CursorStyle::PointingHand)
1582 .on_click(MouseButton::Left, move |_, this, cx| {
1583 this.respond_to_channel_invite(channel_id, true, cx);
1584 }),
1585 )
1586 .constrained()
1587 .with_height(theme.row_height)
1588 .contained()
1589 .with_style(
1590 *theme
1591 .contact_row
1592 .in_state(is_selected)
1593 .style_for(&mut Default::default()),
1594 )
1595 .with_padding_left(
1596 theme.contact_row.default_style().padding.left + theme.channel_indent,
1597 )
1598 .into_any()
1599 }
1600
1601 fn render_contact_request(
1602 user: Arc<User>,
1603 user_store: ModelHandle<UserStore>,
1604 theme: &theme::CollabPanel,
1605 is_incoming: bool,
1606 is_selected: bool,
1607 cx: &mut ViewContext<Self>,
1608 ) -> AnyElement<Self> {
1609 enum Decline {}
1610 enum Accept {}
1611 enum Cancel {}
1612
1613 let mut row = Flex::row()
1614 .with_children(user.avatar.clone().map(|avatar| {
1615 Image::from_data(avatar)
1616 .with_style(theme.contact_avatar)
1617 .aligned()
1618 .left()
1619 }))
1620 .with_child(
1621 Label::new(
1622 user.github_login.clone(),
1623 theme.contact_username.text.clone(),
1624 )
1625 .contained()
1626 .with_style(theme.contact_username.container)
1627 .aligned()
1628 .left()
1629 .flex(1., true),
1630 );
1631
1632 let user_id = user.id;
1633 let github_login = user.github_login.clone();
1634 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1635 let button_spacing = theme.contact_button_spacing;
1636
1637 if is_incoming {
1638 row.add_child(
1639 MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
1640 let button_style = if is_contact_request_pending {
1641 &theme.disabled_button
1642 } else {
1643 theme.contact_button.style_for(mouse_state)
1644 };
1645 render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1646 })
1647 .with_cursor_style(CursorStyle::PointingHand)
1648 .on_click(MouseButton::Left, move |_, this, cx| {
1649 this.respond_to_contact_request(user_id, false, cx);
1650 })
1651 .contained()
1652 .with_margin_right(button_spacing),
1653 );
1654
1655 row.add_child(
1656 MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
1657 let button_style = if is_contact_request_pending {
1658 &theme.disabled_button
1659 } else {
1660 theme.contact_button.style_for(mouse_state)
1661 };
1662 render_icon_button(button_style, "icons/check_8.svg")
1663 .aligned()
1664 .flex_float()
1665 })
1666 .with_cursor_style(CursorStyle::PointingHand)
1667 .on_click(MouseButton::Left, move |_, this, cx| {
1668 this.respond_to_contact_request(user_id, true, cx);
1669 }),
1670 );
1671 } else {
1672 row.add_child(
1673 MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
1674 let button_style = if is_contact_request_pending {
1675 &theme.disabled_button
1676 } else {
1677 theme.contact_button.style_for(mouse_state)
1678 };
1679 render_icon_button(button_style, "icons/x_mark_8.svg")
1680 .aligned()
1681 .flex_float()
1682 })
1683 .with_padding(Padding::uniform(2.))
1684 .with_cursor_style(CursorStyle::PointingHand)
1685 .on_click(MouseButton::Left, move |_, this, cx| {
1686 this.remove_contact(user_id, &github_login, cx);
1687 })
1688 .flex_float(),
1689 );
1690 }
1691
1692 row.constrained()
1693 .with_height(theme.row_height)
1694 .contained()
1695 .with_style(
1696 *theme
1697 .contact_row
1698 .in_state(is_selected)
1699 .style_for(&mut Default::default()),
1700 )
1701 .into_any()
1702 }
1703
1704 fn include_channels_section(&self, cx: &AppContext) -> bool {
1705 if cx.has_global::<StaffMode>() {
1706 cx.global::<StaffMode>().0
1707 } else {
1708 false
1709 }
1710 }
1711
1712 fn deploy_channel_context_menu(
1713 &mut self,
1714 position: Option<Vector2F>,
1715 channel_id: u64,
1716 cx: &mut ViewContext<Self>,
1717 ) {
1718 if self.channel_store.read(cx).is_user_admin(channel_id) {
1719 self.context_menu_on_selected = position.is_none();
1720
1721 self.context_menu.update(cx, |context_menu, cx| {
1722 context_menu.set_position_mode(if self.context_menu_on_selected {
1723 OverlayPositionMode::Local
1724 } else {
1725 OverlayPositionMode::Window
1726 });
1727
1728 context_menu.show(
1729 position.unwrap_or_default(),
1730 if self.context_menu_on_selected {
1731 gpui::elements::AnchorCorner::TopRight
1732 } else {
1733 gpui::elements::AnchorCorner::BottomLeft
1734 },
1735 vec![
1736 ContextMenuItem::action("New Channel", NewChannel { channel_id }),
1737 ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }),
1738 ContextMenuItem::action("Manage members", ManageMembers { channel_id }),
1739 ContextMenuItem::action("Invite members", InviteMembers { channel_id }),
1740 ContextMenuItem::action("Rename Channel", RenameChannel { channel_id }),
1741 ],
1742 cx,
1743 );
1744 });
1745
1746 cx.notify();
1747 }
1748 }
1749
1750 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1751 if self.take_editing_state(cx) {
1752 cx.focus(&self.filter_editor);
1753 } else {
1754 self.filter_editor.update(cx, |editor, cx| {
1755 if editor.buffer().read(cx).len(cx) > 0 {
1756 editor.set_text("", cx);
1757 }
1758 });
1759 }
1760
1761 self.update_entries(false, cx);
1762 }
1763
1764 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1765 let ix = self.selection.map_or(0, |ix| ix + 1);
1766 if ix < self.entries.len() {
1767 self.selection = Some(ix);
1768 }
1769
1770 self.list_state.reset(self.entries.len());
1771 if let Some(ix) = self.selection {
1772 self.list_state.scroll_to(ListOffset {
1773 item_ix: ix,
1774 offset_in_item: 0.,
1775 });
1776 }
1777 cx.notify();
1778 }
1779
1780 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1781 let ix = self.selection.take().unwrap_or(0);
1782 if ix > 0 {
1783 self.selection = Some(ix - 1);
1784 }
1785
1786 self.list_state.reset(self.entries.len());
1787 if let Some(ix) = self.selection {
1788 self.list_state.scroll_to(ListOffset {
1789 item_ix: ix,
1790 offset_in_item: 0.,
1791 });
1792 }
1793 cx.notify();
1794 }
1795
1796 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1797 if self.confirm_channel_edit(cx) {
1798 return;
1799 }
1800
1801 if let Some(selection) = self.selection {
1802 if let Some(entry) = self.entries.get(selection) {
1803 match entry {
1804 ListEntry::Header(section, _) => match section {
1805 Section::ActiveCall => Self::leave_call(cx),
1806 Section::Channels => self.new_root_channel(cx),
1807 Section::Contacts => self.toggle_contact_finder(cx),
1808 Section::ContactRequests
1809 | Section::Online
1810 | Section::Offline
1811 | Section::ChannelInvites => {
1812 self.toggle_expanded(*section, cx);
1813 }
1814 },
1815 ListEntry::Contact { contact, calling } => {
1816 if contact.online && !contact.busy && !calling {
1817 self.call(contact.user.id, Some(self.project.clone()), cx);
1818 }
1819 }
1820 ListEntry::ParticipantProject {
1821 project_id,
1822 host_user_id,
1823 ..
1824 } => {
1825 if let Some(workspace) = self.workspace.upgrade(cx) {
1826 let app_state = workspace.read(cx).app_state().clone();
1827 workspace::join_remote_project(
1828 *project_id,
1829 *host_user_id,
1830 app_state,
1831 cx,
1832 )
1833 .detach_and_log_err(cx);
1834 }
1835 }
1836 ListEntry::ParticipantScreen { peer_id, .. } => {
1837 if let Some(workspace) = self.workspace.upgrade(cx) {
1838 workspace.update(cx, |workspace, cx| {
1839 workspace.open_shared_screen(*peer_id, cx)
1840 });
1841 }
1842 }
1843 ListEntry::Channel(channel) => {
1844 self.join_channel(channel.id, cx);
1845 }
1846 _ => {}
1847 }
1848 }
1849 }
1850 }
1851
1852 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
1853 if let Some(editing_state) = &mut self.channel_editing_state {
1854 match editing_state {
1855 ChannelEditingState::Create {
1856 parent_id,
1857 pending_name,
1858 ..
1859 } => {
1860 if pending_name.is_some() {
1861 return false;
1862 }
1863 let channel_name = self.channel_name_editor.read(cx).text(cx);
1864
1865 *pending_name = Some(channel_name.clone());
1866
1867 self.channel_store
1868 .update(cx, |channel_store, cx| {
1869 channel_store.create_channel(&channel_name, *parent_id, cx)
1870 })
1871 .detach();
1872 cx.notify();
1873 }
1874 ChannelEditingState::Rename {
1875 channel_id,
1876 pending_name,
1877 } => {
1878 if pending_name.is_some() {
1879 return false;
1880 }
1881 let channel_name = self.channel_name_editor.read(cx).text(cx);
1882 *pending_name = Some(channel_name.clone());
1883
1884 self.channel_store
1885 .update(cx, |channel_store, cx| {
1886 channel_store.rename(*channel_id, &channel_name, cx)
1887 })
1888 .detach();
1889 cx.notify();
1890 }
1891 }
1892 cx.focus_self();
1893 true
1894 } else {
1895 false
1896 }
1897 }
1898
1899 fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1900 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1901 self.collapsed_sections.remove(ix);
1902 } else {
1903 self.collapsed_sections.push(section);
1904 }
1905 self.update_entries(false, cx);
1906 }
1907
1908 fn leave_call(cx: &mut ViewContext<Self>) {
1909 ActiveCall::global(cx)
1910 .update(cx, |call, cx| call.hang_up(cx))
1911 .detach_and_log_err(cx);
1912 }
1913
1914 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1915 if let Some(workspace) = self.workspace.upgrade(cx) {
1916 workspace.update(cx, |workspace, cx| {
1917 workspace.toggle_modal(cx, |_, cx| {
1918 cx.add_view(|cx| {
1919 let finder = build_contact_finder(self.user_store.clone(), cx);
1920 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1921 finder
1922 })
1923 });
1924 });
1925 }
1926 }
1927
1928 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
1929 self.channel_editing_state = Some(ChannelEditingState::Create {
1930 parent_id: None,
1931 pending_name: None,
1932 });
1933 self.update_entries(false, cx);
1934 self.select_channel_editor();
1935 cx.focus(self.channel_name_editor.as_any());
1936 cx.notify();
1937 }
1938
1939 fn select_channel_editor(&mut self) {
1940 self.selection = self.entries.iter().position(|entry| match entry {
1941 ListEntry::ChannelEditor { .. } => true,
1942 _ => false,
1943 });
1944 }
1945
1946 fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
1947 self.channel_editing_state = Some(ChannelEditingState::Create {
1948 parent_id: Some(action.channel_id),
1949 pending_name: None,
1950 });
1951 self.update_entries(false, cx);
1952 self.select_channel_editor();
1953 cx.focus(self.channel_name_editor.as_any());
1954 cx.notify();
1955 }
1956
1957 fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext<Self>) {
1958 self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx);
1959 }
1960
1961 fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext<Self>) {
1962 self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx);
1963 }
1964
1965 // TODO: Make join into a toggle
1966 // TODO: Make enter work on channel editor
1967 fn remove(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
1968 if let Some(channel) = self.selected_channel() {
1969 self.remove_channel(channel.id, cx)
1970 }
1971 }
1972
1973 fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
1974 if let Some(channel) = self.selected_channel() {
1975 self.rename_channel(
1976 &RenameChannel {
1977 channel_id: channel.id,
1978 },
1979 cx,
1980 );
1981 }
1982 }
1983
1984 fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext<Self>) {
1985 let channel_store = self.channel_store.read(cx);
1986 if !channel_store.is_user_admin(action.channel_id) {
1987 return;
1988 }
1989 if let Some(channel) = channel_store.channel_for_id(action.channel_id) {
1990 self.channel_editing_state = Some(ChannelEditingState::Rename {
1991 channel_id: action.channel_id,
1992 pending_name: None,
1993 });
1994 self.channel_name_editor.update(cx, |editor, cx| {
1995 editor.set_text(channel.name.clone(), cx);
1996 editor.select_all(&Default::default(), cx);
1997 });
1998 cx.focus(self.channel_name_editor.as_any());
1999 self.update_entries(false, cx);
2000 self.select_channel_editor();
2001 }
2002 }
2003
2004 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
2005 let Some(channel) = self.selected_channel() else {
2006 return;
2007 };
2008
2009 self.deploy_channel_context_menu(None, channel.id, cx);
2010 }
2011
2012 fn selected_channel(&self) -> Option<&Arc<Channel>> {
2013 self.selection
2014 .and_then(|ix| self.entries.get(ix))
2015 .and_then(|entry| match entry {
2016 ListEntry::Channel(channel) => Some(channel),
2017 _ => None,
2018 })
2019 }
2020
2021 fn show_channel_modal(
2022 &mut self,
2023 channel_id: ChannelId,
2024 mode: channel_modal::Mode,
2025 cx: &mut ViewContext<Self>,
2026 ) {
2027 let workspace = self.workspace.clone();
2028 let user_store = self.user_store.clone();
2029 let channel_store = self.channel_store.clone();
2030 let members = self.channel_store.update(cx, |channel_store, cx| {
2031 channel_store.get_channel_member_details(channel_id, cx)
2032 });
2033
2034 cx.spawn(|_, mut cx| async move {
2035 let members = members.await?;
2036 workspace.update(&mut cx, |workspace, cx| {
2037 workspace.toggle_modal(cx, |_, cx| {
2038 cx.add_view(|cx| {
2039 ChannelModal::new(
2040 user_store.clone(),
2041 channel_store.clone(),
2042 channel_id,
2043 mode,
2044 members,
2045 cx,
2046 )
2047 })
2048 });
2049 })
2050 })
2051 .detach();
2052 }
2053
2054 fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
2055 self.remove_channel(action.channel_id, cx)
2056 }
2057
2058 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
2059 let channel_store = self.channel_store.clone();
2060 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
2061 let prompt_message = format!(
2062 "Are you sure you want to remove the channel \"{}\"?",
2063 channel.name
2064 );
2065 let mut answer =
2066 cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
2067 let window = cx.window();
2068 cx.spawn(|_, mut cx| async move {
2069 if answer.next().await == Some(0) {
2070 if let Err(e) = channel_store
2071 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))
2072 .await
2073 {
2074 window.prompt(
2075 PromptLevel::Info,
2076 &format!("Failed to remove channel: {}", e),
2077 &["Ok"],
2078 &mut cx,
2079 );
2080 }
2081 }
2082 })
2083 .detach();
2084 }
2085 }
2086
2087 // Should move to the filter editor if clicking on it
2088 // Should move selection to the channel editor if activating it
2089
2090 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
2091 let user_store = self.user_store.clone();
2092 let prompt_message = format!(
2093 "Are you sure you want to remove \"{}\" from your contacts?",
2094 github_login
2095 );
2096 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
2097 let window = cx.window();
2098 cx.spawn(|_, mut cx| async move {
2099 if answer.next().await == Some(0) {
2100 if let Err(e) = user_store
2101 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
2102 .await
2103 {
2104 window.prompt(
2105 PromptLevel::Info,
2106 &format!("Failed to remove contact: {}", e),
2107 &["Ok"],
2108 &mut cx,
2109 );
2110 }
2111 }
2112 })
2113 .detach();
2114 }
2115
2116 fn respond_to_contact_request(
2117 &mut self,
2118 user_id: u64,
2119 accept: bool,
2120 cx: &mut ViewContext<Self>,
2121 ) {
2122 self.user_store
2123 .update(cx, |store, cx| {
2124 store.respond_to_contact_request(user_id, accept, cx)
2125 })
2126 .detach();
2127 }
2128
2129 fn respond_to_channel_invite(
2130 &mut self,
2131 channel_id: u64,
2132 accept: bool,
2133 cx: &mut ViewContext<Self>,
2134 ) {
2135 let respond = self.channel_store.update(cx, |store, _| {
2136 store.respond_to_channel_invite(channel_id, accept)
2137 });
2138 cx.foreground().spawn(respond).detach();
2139 }
2140
2141 fn call(
2142 &mut self,
2143 recipient_user_id: u64,
2144 initial_project: Option<ModelHandle<Project>>,
2145 cx: &mut ViewContext<Self>,
2146 ) {
2147 ActiveCall::global(cx)
2148 .update(cx, |call, cx| {
2149 call.invite(recipient_user_id, initial_project, cx)
2150 })
2151 .detach_and_log_err(cx);
2152 }
2153
2154 fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
2155 ActiveCall::global(cx)
2156 .update(cx, |call, cx| call.join_channel(channel, cx))
2157 .detach_and_log_err(cx);
2158 }
2159}
2160
2161impl View for CollabPanel {
2162 fn ui_name() -> &'static str {
2163 "CollabPanel"
2164 }
2165
2166 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
2167 if !self.has_focus {
2168 self.has_focus = true;
2169 if !self.context_menu.is_focused(cx) {
2170 if let Some(editing_state) = &self.channel_editing_state {
2171 if editing_state.pending_name().is_none() {
2172 cx.focus(&self.channel_name_editor);
2173 } else {
2174 cx.focus(&self.filter_editor);
2175 }
2176 } else {
2177 cx.focus(&self.filter_editor);
2178 }
2179 }
2180 cx.emit(Event::Focus);
2181 }
2182 }
2183
2184 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
2185 self.has_focus = false;
2186 }
2187
2188 fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
2189 let theme = &theme::current(cx).collab_panel;
2190
2191 if self.user_store.read(cx).current_user().is_none() {
2192 enum LogInButton {}
2193
2194 return Flex::column()
2195 .with_child(
2196 MouseEventHandler::<LogInButton, _>::new(0, cx, |state, _| {
2197 let button = theme.log_in_button.style_for(state);
2198 Label::new("Sign in to collaborate", button.text.clone())
2199 .contained()
2200 .with_style(button.container)
2201 })
2202 .on_click(MouseButton::Left, |_, this, cx| {
2203 let client = this.client.clone();
2204 cx.spawn(|_, cx| async move {
2205 client.authenticate_and_connect(true, &cx).await.log_err();
2206 })
2207 .detach();
2208 })
2209 .with_cursor_style(CursorStyle::PointingHand),
2210 )
2211 .contained()
2212 .with_style(theme.container)
2213 .into_any();
2214 }
2215
2216 enum PanelFocus {}
2217 MouseEventHandler::<PanelFocus, _>::new(0, cx, |_, cx| {
2218 Stack::new()
2219 .with_child(
2220 Flex::column()
2221 .with_child(
2222 Flex::row()
2223 .with_child(
2224 ChildView::new(&self.filter_editor, cx)
2225 .contained()
2226 .with_style(theme.user_query_editor.container)
2227 .flex(1.0, true),
2228 )
2229 .constrained()
2230 .with_width(self.size(cx)),
2231 )
2232 .with_child(
2233 List::new(self.list_state.clone())
2234 .constrained()
2235 .with_width(self.size(cx))
2236 .flex(1., true)
2237 .into_any(),
2238 )
2239 .contained()
2240 .with_style(theme.container)
2241 .constrained()
2242 .with_width(self.size(cx))
2243 .into_any(),
2244 )
2245 .with_children(
2246 (!self.context_menu_on_selected)
2247 .then(|| ChildView::new(&self.context_menu, cx)),
2248 )
2249 .into_any()
2250 })
2251 .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
2252 .into_any_named("channels panel")
2253 }
2254}
2255
2256impl Panel for CollabPanel {
2257 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2258 match settings::get::<CollaborationPanelSettings>(cx).dock {
2259 CollaborationPanelDockPosition::Left => DockPosition::Left,
2260 CollaborationPanelDockPosition::Right => DockPosition::Right,
2261 }
2262 }
2263
2264 fn position_is_valid(&self, position: DockPosition) -> bool {
2265 matches!(position, DockPosition::Left | DockPosition::Right)
2266 }
2267
2268 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2269 settings::update_settings_file::<CollaborationPanelSettings>(
2270 self.fs.clone(),
2271 cx,
2272 move |settings| {
2273 let dock = match position {
2274 DockPosition::Left | DockPosition::Bottom => {
2275 CollaborationPanelDockPosition::Left
2276 }
2277 DockPosition::Right => CollaborationPanelDockPosition::Right,
2278 };
2279 settings.dock = Some(dock);
2280 },
2281 );
2282 }
2283
2284 fn size(&self, cx: &gpui::WindowContext) -> f32 {
2285 self.width
2286 .unwrap_or_else(|| settings::get::<CollaborationPanelSettings>(cx).default_width)
2287 }
2288
2289 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
2290 self.width = Some(size);
2291 self.serialize(cx);
2292 cx.notify();
2293 }
2294
2295 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
2296 settings::get::<CollaborationPanelSettings>(cx)
2297 .button
2298 .then(|| "icons/speech_bubble_12.svg")
2299 }
2300
2301 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
2302 ("Channels Panel".to_string(), Some(Box::new(ToggleFocus)))
2303 }
2304
2305 fn should_change_position_on_event(event: &Self::Event) -> bool {
2306 matches!(event, Event::DockPositionChanged)
2307 }
2308
2309 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
2310 self.has_focus
2311 }
2312
2313 fn is_focus_event(event: &Self::Event) -> bool {
2314 matches!(event, Event::Focus)
2315 }
2316}
2317
2318impl PartialEq for ListEntry {
2319 fn eq(&self, other: &Self) -> bool {
2320 match self {
2321 ListEntry::Header(section_1, depth_1) => {
2322 if let ListEntry::Header(section_2, depth_2) = other {
2323 return section_1 == section_2 && depth_1 == depth_2;
2324 }
2325 }
2326 ListEntry::CallParticipant { user: user_1, .. } => {
2327 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2328 return user_1.id == user_2.id;
2329 }
2330 }
2331 ListEntry::ParticipantProject {
2332 project_id: project_id_1,
2333 ..
2334 } => {
2335 if let ListEntry::ParticipantProject {
2336 project_id: project_id_2,
2337 ..
2338 } = other
2339 {
2340 return project_id_1 == project_id_2;
2341 }
2342 }
2343 ListEntry::ParticipantScreen {
2344 peer_id: peer_id_1, ..
2345 } => {
2346 if let ListEntry::ParticipantScreen {
2347 peer_id: peer_id_2, ..
2348 } = other
2349 {
2350 return peer_id_1 == peer_id_2;
2351 }
2352 }
2353 ListEntry::Channel(channel_1) => {
2354 if let ListEntry::Channel(channel_2) = other {
2355 return channel_1.id == channel_2.id;
2356 }
2357 }
2358 ListEntry::ChannelInvite(channel_1) => {
2359 if let ListEntry::ChannelInvite(channel_2) = other {
2360 return channel_1.id == channel_2.id;
2361 }
2362 }
2363 ListEntry::IncomingRequest(user_1) => {
2364 if let ListEntry::IncomingRequest(user_2) = other {
2365 return user_1.id == user_2.id;
2366 }
2367 }
2368 ListEntry::OutgoingRequest(user_1) => {
2369 if let ListEntry::OutgoingRequest(user_2) = other {
2370 return user_1.id == user_2.id;
2371 }
2372 }
2373 ListEntry::Contact {
2374 contact: contact_1, ..
2375 } => {
2376 if let ListEntry::Contact {
2377 contact: contact_2, ..
2378 } = other
2379 {
2380 return contact_1.user.id == contact_2.user.id;
2381 }
2382 }
2383 ListEntry::ChannelEditor { depth } => {
2384 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2385 return depth == other_depth;
2386 }
2387 }
2388 }
2389 false
2390 }
2391}
2392
2393fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
2394 Svg::new(svg_path)
2395 .with_color(style.color)
2396 .constrained()
2397 .with_width(style.icon_width)
2398 .aligned()
2399 .constrained()
2400 .with_width(style.button_width)
2401 .with_height(style.button_width)
2402 .contained()
2403 .with_style(style.container)
2404}