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                .iter()
346                .map(|(peer_id, collaborator)| (*peer_id, collaborator.clone()))
347                .collect::<Vec<_>>();
348            participants
349                .sort_by_key(|(peer_id, _)| Some(project.collaborators().get(peer_id)?.replica_id));
350            participants
351                .into_iter()
352                .filter_map(|(peer_id, participant)| {
353                    let project = workspace.read(cx).project().read(cx);
354                    let replica_id = project
355                        .collaborators()
356                        .get(&peer_id)
357                        .map(|collaborator| collaborator.replica_id);
358                    let user = participant.user.clone();
359                    Some(self.render_avatar(
360                        &user,
361                        replica_id,
362                        Some((peer_id, &user.github_login, participant.location)),
363                        workspace,
364                        theme,
365                        cx,
366                    ))
367                })
368                .collect()
369        } else {
370            Default::default()
371        }
372    }
373
374    fn render_current_user(
375        &self,
376        workspace: &ViewHandle<Workspace>,
377        theme: &Theme,
378        cx: &mut RenderContext<Self>,
379    ) -> Option<ElementBox> {
380        let user = workspace.read(cx).user_store().read(cx).current_user();
381        let replica_id = workspace.read(cx).project().read(cx).replica_id();
382        let status = *workspace.read(cx).client().status().borrow();
383        if let Some(user) = user {
384            Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
385        } else if matches!(status, client::Status::UpgradeRequired) {
386            None
387        } else {
388            Some(
389                MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
390                    let style = theme
391                        .workspace
392                        .titlebar
393                        .sign_in_prompt
394                        .style_for(state, false);
395                    Label::new("Sign in".to_string(), style.text.clone())
396                        .contained()
397                        .with_style(style.container)
398                        .boxed()
399                })
400                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
401                .with_cursor_style(CursorStyle::PointingHand)
402                .aligned()
403                .boxed(),
404            )
405        }
406    }
407
408    fn render_avatar(
409        &self,
410        user: &User,
411        replica_id: Option<ReplicaId>,
412        peer: Option<(PeerId, &str, ParticipantLocation)>,
413        workspace: &ViewHandle<Workspace>,
414        theme: &Theme,
415        cx: &mut RenderContext<Self>,
416    ) -> ElementBox {
417        let is_followed = peer.map_or(false, |(peer_id, _, _)| {
418            workspace.read(cx).is_following(peer_id)
419        });
420
421        let mut avatar_style;
422        if let Some((_, _, location)) = peer.as_ref() {
423            if let ParticipantLocation::SharedProject { project_id } = *location {
424                if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
425                    avatar_style = theme.workspace.titlebar.avatar;
426                } else {
427                    avatar_style = theme.workspace.titlebar.inactive_avatar;
428                }
429            } else {
430                avatar_style = theme.workspace.titlebar.inactive_avatar;
431            }
432        } else {
433            avatar_style = theme.workspace.titlebar.avatar;
434        }
435
436        let mut replica_color = None;
437        if let Some(replica_id) = replica_id {
438            let color = theme.editor.replica_selection_style(replica_id).cursor;
439            replica_color = Some(color);
440            if is_followed {
441                avatar_style.border = Border::all(1.0, color);
442            }
443        }
444
445        let content = Stack::new()
446            .with_children(user.avatar.as_ref().map(|avatar| {
447                Image::new(avatar.clone())
448                    .with_style(avatar_style)
449                    .constrained()
450                    .with_width(theme.workspace.titlebar.avatar_width)
451                    .aligned()
452                    .boxed()
453            }))
454            .with_children(replica_color.map(|replica_color| {
455                AvatarRibbon::new(replica_color)
456                    .constrained()
457                    .with_width(theme.workspace.titlebar.avatar_ribbon.width)
458                    .with_height(theme.workspace.titlebar.avatar_ribbon.height)
459                    .aligned()
460                    .bottom()
461                    .boxed()
462            }))
463            .constrained()
464            .with_width(theme.workspace.titlebar.avatar_width)
465            .contained()
466            .with_margin_left(theme.workspace.titlebar.avatar_margin)
467            .boxed();
468
469        if let Some((peer_id, peer_github_login, location)) = peer {
470            if let Some(replica_id) = replica_id {
471                MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
472                    .with_cursor_style(CursorStyle::PointingHand)
473                    .on_click(MouseButton::Left, move |_, cx| {
474                        cx.dispatch_action(ToggleFollow(peer_id))
475                    })
476                    .with_tooltip::<ToggleFollow, _>(
477                        peer_id.as_u64() as usize,
478                        if is_followed {
479                            format!("Unfollow {}", peer_github_login)
480                        } else {
481                            format!("Follow {}", peer_github_login)
482                        },
483                        Some(Box::new(FollowNextCollaborator)),
484                        theme.tooltip.clone(),
485                        cx,
486                    )
487                    .boxed()
488            } else if let ParticipantLocation::SharedProject { project_id } = location {
489                let user_id = user.id;
490                MouseEventHandler::<JoinProject>::new(peer_id.as_u64() as usize, cx, move |_, _| {
491                    content
492                })
493                .with_cursor_style(CursorStyle::PointingHand)
494                .on_click(MouseButton::Left, move |_, cx| {
495                    cx.dispatch_action(JoinProject {
496                        project_id,
497                        follow_user_id: user_id,
498                    })
499                })
500                .with_tooltip::<JoinProject, _>(
501                    peer_id.as_u64() as usize,
502                    format!("Follow {} into external project", peer_github_login),
503                    Some(Box::new(FollowNextCollaborator)),
504                    theme.tooltip.clone(),
505                    cx,
506                )
507                .boxed()
508            } else {
509                content
510            }
511        } else {
512            content
513        }
514    }
515
516    fn render_connection_status(
517        &self,
518        workspace: &ViewHandle<Workspace>,
519        cx: &mut RenderContext<Self>,
520    ) -> Option<ElementBox> {
521        let theme = &cx.global::<Settings>().theme;
522        match &*workspace.read(cx).client().status().borrow() {
523            client::Status::ConnectionError
524            | client::Status::ConnectionLost
525            | client::Status::Reauthenticating { .. }
526            | client::Status::Reconnecting { .. }
527            | client::Status::ReconnectionError { .. } => Some(
528                Container::new(
529                    Align::new(
530                        ConstrainedBox::new(
531                            Svg::new("icons/cloud_slash_12.svg")
532                                .with_color(theme.workspace.titlebar.offline_icon.color)
533                                .boxed(),
534                        )
535                        .with_width(theme.workspace.titlebar.offline_icon.width)
536                        .boxed(),
537                    )
538                    .boxed(),
539                )
540                .with_style(theme.workspace.titlebar.offline_icon.container)
541                .boxed(),
542            ),
543            client::Status::UpgradeRequired => Some(
544                Label::new(
545                    "Please update Zed to collaborate".to_string(),
546                    theme.workspace.titlebar.outdated_warning.text.clone(),
547                )
548                .contained()
549                .with_style(theme.workspace.titlebar.outdated_warning.container)
550                .aligned()
551                .boxed(),
552            ),
553            _ => None,
554        }
555    }
556}
557
558pub struct AvatarRibbon {
559    color: Color,
560}
561
562impl AvatarRibbon {
563    pub fn new(color: Color) -> AvatarRibbon {
564        AvatarRibbon { color }
565    }
566}
567
568impl Element for AvatarRibbon {
569    type LayoutState = ();
570
571    type PaintState = ();
572
573    fn layout(
574        &mut self,
575        constraint: gpui::SizeConstraint,
576        _: &mut gpui::LayoutContext,
577    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
578        (constraint.max, ())
579    }
580
581    fn paint(
582        &mut self,
583        bounds: gpui::geometry::rect::RectF,
584        _: gpui::geometry::rect::RectF,
585        _: &mut Self::LayoutState,
586        cx: &mut gpui::PaintContext,
587    ) -> Self::PaintState {
588        let mut path = PathBuilder::new();
589        path.reset(bounds.lower_left());
590        path.curve_to(
591            bounds.origin() + vec2f(bounds.height(), 0.),
592            bounds.origin(),
593        );
594        path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
595        path.curve_to(bounds.lower_right(), bounds.upper_right());
596        path.line_to(bounds.lower_left());
597        cx.scene.push_path(path.build(self.color, None));
598    }
599
600    fn rect_for_text_range(
601        &self,
602        _: Range<usize>,
603        _: RectF,
604        _: RectF,
605        _: &Self::LayoutState,
606        _: &Self::PaintState,
607        _: &gpui::MeasurementContext,
608    ) -> Option<RectF> {
609        None
610    }
611
612    fn debug(
613        &self,
614        bounds: gpui::geometry::rect::RectF,
615        _: &Self::LayoutState,
616        _: &Self::PaintState,
617        _: &gpui::DebugContext,
618    ) -> gpui::json::Value {
619        json::json!({
620            "type": "AvatarRibbon",
621            "bounds": bounds.to_json(),
622            "color": self.color.to_json(),
623        })
624    }
625}