collab_titlebar_item.rs

  1use crate::{contact_notification::ContactNotification, contacts_popover};
  2use call::{ActiveCall, ParticipantLocation};
  3use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore};
  4use clock::ReplicaId;
  5use contacts_popover::ContactsPopover;
  6use gpui::{
  7    actions,
  8    color::Color,
  9    elements::*,
 10    geometry::{rect::RectF, vector::vec2f, PathBuilder},
 11    json::{self, ToJson},
 12    Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
 13    Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
 14};
 15use settings::Settings;
 16use std::ops::Range;
 17use theme::Theme;
 18use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
 19
 20actions!(
 21    collab,
 22    [ToggleCollaborationMenu, ToggleScreenSharing, ShareProject]
 23);
 24
 25pub fn init(cx: &mut MutableAppContext) {
 26    cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
 27    cx.add_action(CollabTitlebarItem::toggle_screen_sharing);
 28    cx.add_action(CollabTitlebarItem::share_project);
 29}
 30
 31pub struct CollabTitlebarItem {
 32    workspace: WeakViewHandle<Workspace>,
 33    user_store: ModelHandle<UserStore>,
 34    contacts_popover: Option<ViewHandle<ContactsPopover>>,
 35    _subscriptions: Vec<Subscription>,
 36}
 37
 38impl Entity for CollabTitlebarItem {
 39    type Event = ();
 40}
 41
 42impl View for CollabTitlebarItem {
 43    fn ui_name() -> &'static str {
 44        "CollabTitlebarItem"
 45    }
 46
 47    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
 48        let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
 49            workspace
 50        } else {
 51            return Empty::new().boxed();
 52        };
 53
 54        let theme = cx.global::<Settings>().theme.clone();
 55
 56        let mut container = Flex::row();
 57
 58        container.add_children(self.render_toggle_screen_sharing_button(&theme, cx));
 59
 60        if workspace.read(cx).client().status().borrow().is_connected() {
 61            let project = workspace.read(cx).project().read(cx);
 62            if project.is_shared()
 63                || project.is_remote()
 64                || ActiveCall::global(cx).read(cx).room().is_none()
 65            {
 66                container.add_child(self.render_toggle_contacts_button(&theme, cx));
 67            } else {
 68                container.add_child(self.render_share_button(&theme, cx));
 69            }
 70        }
 71        container.add_children(self.render_collaborators(&workspace, &theme, cx));
 72        container.add_children(self.render_current_user(&workspace, &theme, cx));
 73        container.add_children(self.render_connection_status(&workspace, cx));
 74        container.boxed()
 75    }
 76}
 77
 78impl CollabTitlebarItem {
 79    pub fn new(
 80        workspace: &ViewHandle<Workspace>,
 81        user_store: &ModelHandle<UserStore>,
 82        cx: &mut ViewContext<Self>,
 83    ) -> Self {
 84        let active_call = ActiveCall::global(cx);
 85        let mut subscriptions = Vec::new();
 86        subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify()));
 87        subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify()));
 88        subscriptions.push(cx.observe_window_activation(|this, active, cx| {
 89            this.window_activation_changed(active, cx)
 90        }));
 91        subscriptions.push(cx.observe(user_store, |_, _, cx| cx.notify()));
 92        subscriptions.push(
 93            cx.subscribe(user_store, move |this, user_store, event, cx| {
 94                if let Some(workspace) = this.workspace.upgrade(cx) {
 95                    workspace.update(cx, |workspace, cx| {
 96                        if let client::Event::Contact { user, kind } = event {
 97                            if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
 98                                workspace.show_notification(user.id as usize, cx, |cx| {
 99                                    cx.add_view(|cx| {
100                                        ContactNotification::new(
101                                            user.clone(),
102                                            *kind,
103                                            user_store,
104                                            cx,
105                                        )
106                                    })
107                                })
108                            }
109                        }
110                    });
111                }
112            }),
113        );
114
115        Self {
116            workspace: workspace.downgrade(),
117            user_store: user_store.clone(),
118            contacts_popover: None,
119            _subscriptions: subscriptions,
120        }
121    }
122
123    fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
124        if let Some(workspace) = self.workspace.upgrade(cx) {
125            let project = if active {
126                Some(workspace.read(cx).project().clone())
127            } else {
128                None
129            };
130            ActiveCall::global(cx)
131                .update(cx, |call, cx| call.set_location(project.as_ref(), cx))
132                .detach_and_log_err(cx);
133        }
134    }
135
136    fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
137        if let Some(workspace) = self.workspace.upgrade(cx) {
138            let active_call = ActiveCall::global(cx);
139            let project = workspace.read(cx).project().clone();
140            active_call
141                .update(cx, |call, cx| call.share_project(project, cx))
142                .detach_and_log_err(cx);
143        }
144    }
145
146    pub fn toggle_contacts_popover(
147        &mut self,
148        _: &ToggleCollaborationMenu,
149        cx: &mut ViewContext<Self>,
150    ) {
151        match self.contacts_popover.take() {
152            Some(_) => {}
153            None => {
154                if let Some(workspace) = self.workspace.upgrade(cx) {
155                    let project = workspace.read(cx).project().clone();
156                    let user_store = workspace.read(cx).user_store().clone();
157                    let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
158                    cx.subscribe(&view, |this, _, event, cx| {
159                        match event {
160                            contacts_popover::Event::Dismissed => {
161                                this.contacts_popover = None;
162                            }
163                        }
164
165                        cx.notify();
166                    })
167                    .detach();
168                    self.contacts_popover = Some(view);
169                }
170            }
171        }
172        cx.notify();
173    }
174
175    pub fn toggle_screen_sharing(&mut self, _: &ToggleScreenSharing, cx: &mut ViewContext<Self>) {
176        if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
177            let toggle_screen_sharing = room.update(cx, |room, cx| {
178                if room.is_screen_sharing() {
179                    Task::ready(room.unshare_screen(cx))
180                } else {
181                    room.share_screen(cx)
182                }
183            });
184            toggle_screen_sharing.detach_and_log_err(cx);
185        }
186    }
187
188    fn render_toggle_contacts_button(
189        &self,
190        theme: &Theme,
191        cx: &mut RenderContext<Self>,
192    ) -> ElementBox {
193        let titlebar = &theme.workspace.titlebar;
194        let badge = if self
195            .user_store
196            .read(cx)
197            .incoming_contact_requests()
198            .is_empty()
199        {
200            None
201        } else {
202            Some(
203                Empty::new()
204                    .collapsed()
205                    .contained()
206                    .with_style(titlebar.toggle_contacts_badge)
207                    .contained()
208                    .with_margin_left(titlebar.toggle_contacts_button.default.icon_width)
209                    .with_margin_top(titlebar.toggle_contacts_button.default.icon_width)
210                    .aligned()
211                    .boxed(),
212            )
213        };
214        Stack::new()
215            .with_child(
216                MouseEventHandler::<ToggleCollaborationMenu>::new(0, cx, |state, _| {
217                    let style = titlebar
218                        .toggle_contacts_button
219                        .style_for(state, self.contacts_popover.is_some());
220                    Svg::new("icons/plus_8.svg")
221                        .with_color(style.color)
222                        .constrained()
223                        .with_width(style.icon_width)
224                        .aligned()
225                        .constrained()
226                        .with_width(style.button_width)
227                        .with_height(style.button_width)
228                        .contained()
229                        .with_style(style.container)
230                        .boxed()
231                })
232                .with_cursor_style(CursorStyle::PointingHand)
233                .on_click(MouseButton::Left, move |_, cx| {
234                    cx.dispatch_action(ToggleCollaborationMenu);
235                })
236                .aligned()
237                .boxed(),
238            )
239            .with_children(badge)
240            .with_children(self.contacts_popover.as_ref().map(|popover| {
241                Overlay::new(
242                    ChildView::new(popover, cx)
243                        .contained()
244                        .with_margin_top(titlebar.height)
245                        .with_margin_left(titlebar.toggle_contacts_button.default.button_width)
246                        .with_margin_right(-titlebar.toggle_contacts_button.default.button_width)
247                        .boxed(),
248                )
249                .with_fit_mode(OverlayFitMode::SwitchAnchor)
250                .with_anchor_corner(AnchorCorner::BottomLeft)
251                .with_z_index(999)
252                .boxed()
253            }))
254            .boxed()
255    }
256
257    fn render_toggle_screen_sharing_button(
258        &self,
259        theme: &Theme,
260        cx: &mut RenderContext<Self>,
261    ) -> Option<ElementBox> {
262        let active_call = ActiveCall::global(cx);
263        let room = active_call.read(cx).room().cloned()?;
264        let icon;
265        let tooltip;
266
267        if room.read(cx).is_screen_sharing() {
268            icon = "icons/disable_screen_sharing_12.svg";
269            tooltip = "Stop Sharing Screen"
270        } else {
271            icon = "icons/enable_screen_sharing_12.svg";
272            tooltip = "Share Screen";
273        }
274
275        let titlebar = &theme.workspace.titlebar;
276        Some(
277            MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
278                let style = titlebar.call_control.style_for(state, false);
279                Svg::new(icon)
280                    .with_color(style.color)
281                    .constrained()
282                    .with_width(style.icon_width)
283                    .aligned()
284                    .constrained()
285                    .with_width(style.button_width)
286                    .with_height(style.button_width)
287                    .contained()
288                    .with_style(style.container)
289                    .boxed()
290            })
291            .with_cursor_style(CursorStyle::PointingHand)
292            .on_click(MouseButton::Left, move |_, cx| {
293                cx.dispatch_action(ToggleScreenSharing);
294            })
295            .with_tooltip::<ToggleScreenSharing, _>(
296                0,
297                tooltip.into(),
298                Some(Box::new(ToggleScreenSharing)),
299                theme.tooltip.clone(),
300                cx,
301            )
302            .aligned()
303            .boxed(),
304        )
305    }
306
307    fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
308        enum Share {}
309
310        let titlebar = &theme.workspace.titlebar;
311        MouseEventHandler::<Share>::new(0, cx, |state, _| {
312            let style = titlebar.share_button.style_for(state, false);
313            Label::new("Share".into(), style.text.clone())
314                .contained()
315                .with_style(style.container)
316                .boxed()
317        })
318        .with_cursor_style(CursorStyle::PointingHand)
319        .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
320        .with_tooltip::<Share, _>(
321            0,
322            "Share project with call participants".into(),
323            None,
324            theme.tooltip.clone(),
325            cx,
326        )
327        .aligned()
328        .contained()
329        .with_margin_left(theme.workspace.titlebar.avatar_margin)
330        .boxed()
331    }
332
333    fn render_collaborators(
334        &self,
335        workspace: &ViewHandle<Workspace>,
336        theme: &Theme,
337        cx: &mut RenderContext<Self>,
338    ) -> Vec<ElementBox> {
339        let active_call = ActiveCall::global(cx);
340        if let Some(room) = active_call.read(cx).room().cloned() {
341            let project = workspace.read(cx).project().read(cx);
342            let mut participants = room
343                .read(cx)
344                .remote_participants()
345                .values()
346                .cloned()
347                .collect::<Vec<_>>();
348            participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id));
349            participants
350                .into_iter()
351                .filter_map(|participant| {
352                    let project = workspace.read(cx).project().read(cx);
353                    let replica_id = project
354                        .collaborators()
355                        .get(&participant.peer_id)
356                        .map(|collaborator| collaborator.replica_id);
357                    let user = participant.user.clone();
358                    Some(self.render_avatar(
359                        &user,
360                        replica_id,
361                        Some((
362                            participant.peer_id,
363                            &user.github_login,
364                            participant.location,
365                        )),
366                        workspace,
367                        theme,
368                        cx,
369                    ))
370                })
371                .collect()
372        } else {
373            Default::default()
374        }
375    }
376
377    fn render_current_user(
378        &self,
379        workspace: &ViewHandle<Workspace>,
380        theme: &Theme,
381        cx: &mut RenderContext<Self>,
382    ) -> Option<ElementBox> {
383        let user = workspace.read(cx).user_store().read(cx).current_user();
384        let replica_id = workspace.read(cx).project().read(cx).replica_id();
385        let status = *workspace.read(cx).client().status().borrow();
386        if let Some(user) = user {
387            Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
388        } else if matches!(status, client::Status::UpgradeRequired) {
389            None
390        } else {
391            Some(
392                MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
393                    let style = theme
394                        .workspace
395                        .titlebar
396                        .sign_in_prompt
397                        .style_for(state, false);
398                    Label::new("Sign in".to_string(), style.text.clone())
399                        .contained()
400                        .with_style(style.container)
401                        .boxed()
402                })
403                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
404                .with_cursor_style(CursorStyle::PointingHand)
405                .aligned()
406                .boxed(),
407            )
408        }
409    }
410
411    fn render_avatar(
412        &self,
413        user: &User,
414        replica_id: Option<ReplicaId>,
415        peer: Option<(PeerId, &str, ParticipantLocation)>,
416        workspace: &ViewHandle<Workspace>,
417        theme: &Theme,
418        cx: &mut RenderContext<Self>,
419    ) -> ElementBox {
420        let is_followed = peer.map_or(false, |(peer_id, _, _)| {
421            workspace.read(cx).is_following(peer_id)
422        });
423
424        let mut avatar_style;
425        if let Some((_, _, location)) = peer.as_ref() {
426            if let ParticipantLocation::SharedProject { project_id } = *location {
427                if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
428                    avatar_style = theme.workspace.titlebar.avatar;
429                } else {
430                    avatar_style = theme.workspace.titlebar.inactive_avatar;
431                }
432            } else {
433                avatar_style = theme.workspace.titlebar.inactive_avatar;
434            }
435        } else {
436            avatar_style = theme.workspace.titlebar.avatar;
437        }
438
439        let mut replica_color = None;
440        if let Some(replica_id) = replica_id {
441            let color = theme.editor.replica_selection_style(replica_id).cursor;
442            replica_color = Some(color);
443            if is_followed {
444                avatar_style.border = Border::all(1.0, color);
445            }
446        }
447
448        let content = Stack::new()
449            .with_children(user.avatar.as_ref().map(|avatar| {
450                Image::new(avatar.clone())
451                    .with_style(avatar_style)
452                    .constrained()
453                    .with_width(theme.workspace.titlebar.avatar_width)
454                    .aligned()
455                    .boxed()
456            }))
457            .with_children(replica_color.map(|replica_color| {
458                AvatarRibbon::new(replica_color)
459                    .constrained()
460                    .with_width(theme.workspace.titlebar.avatar_ribbon.width)
461                    .with_height(theme.workspace.titlebar.avatar_ribbon.height)
462                    .aligned()
463                    .bottom()
464                    .boxed()
465            }))
466            .constrained()
467            .with_width(theme.workspace.titlebar.avatar_width)
468            .contained()
469            .with_margin_left(theme.workspace.titlebar.avatar_margin)
470            .boxed();
471
472        if let Some((peer_id, peer_github_login, location)) = peer {
473            if let Some(replica_id) = replica_id {
474                MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
475                    .with_cursor_style(CursorStyle::PointingHand)
476                    .on_click(MouseButton::Left, move |_, cx| {
477                        cx.dispatch_action(ToggleFollow(peer_id))
478                    })
479                    .with_tooltip::<ToggleFollow, _>(
480                        peer_id.as_u64() as usize,
481                        if is_followed {
482                            format!("Unfollow {}", peer_github_login)
483                        } else {
484                            format!("Follow {}", peer_github_login)
485                        },
486                        Some(Box::new(FollowNextCollaborator)),
487                        theme.tooltip.clone(),
488                        cx,
489                    )
490                    .boxed()
491            } else if let ParticipantLocation::SharedProject { project_id } = location {
492                let user_id = user.id;
493                MouseEventHandler::<JoinProject>::new(peer_id.as_u64() as usize, cx, move |_, _| {
494                    content
495                })
496                .with_cursor_style(CursorStyle::PointingHand)
497                .on_click(MouseButton::Left, move |_, cx| {
498                    cx.dispatch_action(JoinProject {
499                        project_id,
500                        follow_user_id: user_id,
501                    })
502                })
503                .with_tooltip::<JoinProject, _>(
504                    peer_id.as_u64() as usize,
505                    format!("Follow {} into external project", peer_github_login),
506                    Some(Box::new(FollowNextCollaborator)),
507                    theme.tooltip.clone(),
508                    cx,
509                )
510                .boxed()
511            } else {
512                content
513            }
514        } else {
515            content
516        }
517    }
518
519    fn render_connection_status(
520        &self,
521        workspace: &ViewHandle<Workspace>,
522        cx: &mut RenderContext<Self>,
523    ) -> Option<ElementBox> {
524        let theme = &cx.global::<Settings>().theme;
525        match &*workspace.read(cx).client().status().borrow() {
526            client::Status::ConnectionError
527            | client::Status::ConnectionLost
528            | client::Status::Reauthenticating { .. }
529            | client::Status::Reconnecting { .. }
530            | client::Status::ReconnectionError { .. } => Some(
531                Container::new(
532                    Align::new(
533                        ConstrainedBox::new(
534                            Svg::new("icons/cloud_slash_12.svg")
535                                .with_color(theme.workspace.titlebar.offline_icon.color)
536                                .boxed(),
537                        )
538                        .with_width(theme.workspace.titlebar.offline_icon.width)
539                        .boxed(),
540                    )
541                    .boxed(),
542                )
543                .with_style(theme.workspace.titlebar.offline_icon.container)
544                .boxed(),
545            ),
546            client::Status::UpgradeRequired => Some(
547                Label::new(
548                    "Please update Zed to collaborate".to_string(),
549                    theme.workspace.titlebar.outdated_warning.text.clone(),
550                )
551                .contained()
552                .with_style(theme.workspace.titlebar.outdated_warning.container)
553                .aligned()
554                .boxed(),
555            ),
556            _ => None,
557        }
558    }
559}
560
561pub struct AvatarRibbon {
562    color: Color,
563}
564
565impl AvatarRibbon {
566    pub fn new(color: Color) -> AvatarRibbon {
567        AvatarRibbon { color }
568    }
569}
570
571impl Element for AvatarRibbon {
572    type LayoutState = ();
573
574    type PaintState = ();
575
576    fn layout(
577        &mut self,
578        constraint: gpui::SizeConstraint,
579        _: &mut gpui::LayoutContext,
580    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
581        (constraint.max, ())
582    }
583
584    fn paint(
585        &mut self,
586        bounds: gpui::geometry::rect::RectF,
587        _: gpui::geometry::rect::RectF,
588        _: &mut Self::LayoutState,
589        cx: &mut gpui::PaintContext,
590    ) -> Self::PaintState {
591        let mut path = PathBuilder::new();
592        path.reset(bounds.lower_left());
593        path.curve_to(
594            bounds.origin() + vec2f(bounds.height(), 0.),
595            bounds.origin(),
596        );
597        path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
598        path.curve_to(bounds.lower_right(), bounds.upper_right());
599        path.line_to(bounds.lower_left());
600        cx.scene.push_path(path.build(self.color, None));
601    }
602
603    fn rect_for_text_range(
604        &self,
605        _: Range<usize>,
606        _: RectF,
607        _: RectF,
608        _: &Self::LayoutState,
609        _: &Self::PaintState,
610        _: &gpui::MeasurementContext,
611    ) -> Option<RectF> {
612        None
613    }
614
615    fn debug(
616        &self,
617        bounds: gpui::geometry::rect::RectF,
618        _: &Self::LayoutState,
619        _: &Self::PaintState,
620        _: &gpui::DebugContext,
621    ) -> gpui::json::Value {
622        json::json!({
623            "type": "AvatarRibbon",
624            "bounds": bounds.to_json(),
625            "color": self.color.to_json(),
626        })
627    }
628}