collab_titlebar_item.rs

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