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                .with_z_index(999)
251                .boxed()
252            }))
253            .boxed()
254    }
255
256    fn render_toggle_screen_sharing_button(
257        &self,
258        theme: &Theme,
259        cx: &mut RenderContext<Self>,
260    ) -> Option<ElementBox> {
261        let active_call = ActiveCall::global(cx);
262        let room = active_call.read(cx).room().cloned()?;
263        let icon;
264        let tooltip;
265
266        if room.read(cx).is_screen_sharing() {
267            icon = "icons/disable_screen_sharing_12.svg";
268            tooltip = "Stop Sharing Screen"
269        } else {
270            icon = "icons/enable_screen_sharing_12.svg";
271            tooltip = "Share Screen";
272        }
273
274        let titlebar = &theme.workspace.titlebar;
275        Some(
276            MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
277                let style = titlebar.call_control.style_for(state, false);
278                Svg::new(icon)
279                    .with_color(style.color)
280                    .constrained()
281                    .with_width(style.icon_width)
282                    .aligned()
283                    .constrained()
284                    .with_width(style.button_width)
285                    .with_height(style.button_width)
286                    .contained()
287                    .with_style(style.container)
288                    .boxed()
289            })
290            .with_cursor_style(CursorStyle::PointingHand)
291            .on_click(MouseButton::Left, move |_, cx| {
292                cx.dispatch_action(ToggleScreenSharing);
293            })
294            .with_tooltip::<ToggleScreenSharing, _>(
295                0,
296                tooltip.into(),
297                Some(Box::new(ToggleScreenSharing)),
298                theme.tooltip.clone(),
299                cx,
300            )
301            .aligned()
302            .boxed(),
303        )
304    }
305
306    fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
307        enum Share {}
308
309        let titlebar = &theme.workspace.titlebar;
310        MouseEventHandler::<Share>::new(0, cx, |state, _| {
311            let style = titlebar.share_button.style_for(state, false);
312            Label::new("Share".into(), style.text.clone())
313                .contained()
314                .with_style(style.container)
315                .boxed()
316        })
317        .with_cursor_style(CursorStyle::PointingHand)
318        .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
319        .with_tooltip::<Share, _>(
320            0,
321            "Share project with call participants".into(),
322            None,
323            theme.tooltip.clone(),
324            cx,
325        )
326        .aligned()
327        .boxed()
328    }
329
330    fn render_collaborators(
331        &self,
332        workspace: &ViewHandle<Workspace>,
333        theme: &Theme,
334        cx: &mut RenderContext<Self>,
335    ) -> Vec<ElementBox> {
336        let active_call = ActiveCall::global(cx);
337        if let Some(room) = active_call.read(cx).room().cloned() {
338            let project = workspace.read(cx).project().read(cx);
339            let mut participants = room
340                .read(cx)
341                .remote_participants()
342                .iter()
343                .map(|(peer_id, collaborator)| (*peer_id, collaborator.clone()))
344                .collect::<Vec<_>>();
345            participants
346                .sort_by_key(|(peer_id, _)| Some(project.collaborators().get(peer_id)?.replica_id));
347            participants
348                .into_iter()
349                .filter_map(|(peer_id, participant)| {
350                    let project = workspace.read(cx).project().read(cx);
351                    let replica_id = project
352                        .collaborators()
353                        .get(&peer_id)
354                        .map(|collaborator| collaborator.replica_id);
355                    let user = participant.user.clone();
356                    Some(self.render_avatar(
357                        &user,
358                        replica_id,
359                        Some((peer_id, &user.github_login, participant.location)),
360                        workspace,
361                        theme,
362                        cx,
363                    ))
364                })
365                .collect()
366        } else {
367            Default::default()
368        }
369    }
370
371    fn render_current_user(
372        &self,
373        workspace: &ViewHandle<Workspace>,
374        theme: &Theme,
375        cx: &mut RenderContext<Self>,
376    ) -> Option<ElementBox> {
377        let user = workspace.read(cx).user_store().read(cx).current_user();
378        let replica_id = workspace.read(cx).project().read(cx).replica_id();
379        let status = *workspace.read(cx).client().status().borrow();
380        if let Some(user) = user {
381            Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
382        } else if matches!(status, client::Status::UpgradeRequired) {
383            None
384        } else {
385            Some(
386                MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
387                    let style = theme
388                        .workspace
389                        .titlebar
390                        .sign_in_prompt
391                        .style_for(state, false);
392                    Label::new("Sign in".to_string(), style.text.clone())
393                        .contained()
394                        .with_style(style.container)
395                        .boxed()
396                })
397                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
398                .with_cursor_style(CursorStyle::PointingHand)
399                .aligned()
400                .boxed(),
401            )
402        }
403    }
404
405    fn render_avatar(
406        &self,
407        user: &User,
408        replica_id: Option<ReplicaId>,
409        peer: Option<(PeerId, &str, ParticipantLocation)>,
410        workspace: &ViewHandle<Workspace>,
411        theme: &Theme,
412        cx: &mut RenderContext<Self>,
413    ) -> ElementBox {
414        let is_followed = peer.map_or(false, |(peer_id, _, _)| {
415            workspace.read(cx).is_following(peer_id)
416        });
417
418        let mut avatar_style;
419        if let Some((_, _, location)) = peer.as_ref() {
420            if let ParticipantLocation::SharedProject { project_id } = *location {
421                if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
422                    avatar_style = theme.workspace.titlebar.avatar;
423                } else {
424                    avatar_style = theme.workspace.titlebar.inactive_avatar;
425                }
426            } else {
427                avatar_style = theme.workspace.titlebar.inactive_avatar;
428            }
429        } else {
430            avatar_style = theme.workspace.titlebar.avatar;
431        }
432
433        let mut replica_color = None;
434        if let Some(replica_id) = replica_id {
435            let color = theme.editor.replica_selection_style(replica_id).cursor;
436            replica_color = Some(color);
437            if is_followed {
438                avatar_style.border = Border::all(1.0, color);
439            }
440        }
441
442        let content = Stack::new()
443            .with_children(user.avatar.as_ref().map(|avatar| {
444                Image::new(avatar.clone())
445                    .with_style(avatar_style)
446                    .constrained()
447                    .with_width(theme.workspace.titlebar.avatar_width)
448                    .aligned()
449                    .boxed()
450            }))
451            .with_children(replica_color.map(|replica_color| {
452                AvatarRibbon::new(replica_color)
453                    .constrained()
454                    .with_width(theme.workspace.titlebar.avatar_ribbon.width)
455                    .with_height(theme.workspace.titlebar.avatar_ribbon.height)
456                    .aligned()
457                    .bottom()
458                    .boxed()
459            }))
460            .constrained()
461            .with_width(theme.workspace.titlebar.avatar_width)
462            .contained()
463            .with_margin_left(theme.workspace.titlebar.avatar_margin)
464            .boxed();
465
466        if let Some((peer_id, peer_github_login, location)) = peer {
467            if let Some(replica_id) = replica_id {
468                MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
469                    .with_cursor_style(CursorStyle::PointingHand)
470                    .on_click(MouseButton::Left, move |_, cx| {
471                        cx.dispatch_action(ToggleFollow(peer_id))
472                    })
473                    .with_tooltip::<ToggleFollow, _>(
474                        peer_id.0 as usize,
475                        if is_followed {
476                            format!("Unfollow {}", peer_github_login)
477                        } else {
478                            format!("Follow {}", peer_github_login)
479                        },
480                        Some(Box::new(FollowNextCollaborator)),
481                        theme.tooltip.clone(),
482                        cx,
483                    )
484                    .boxed()
485            } else if let ParticipantLocation::SharedProject { project_id } = location {
486                let user_id = user.id;
487                MouseEventHandler::<JoinProject>::new(peer_id.0 as usize, cx, move |_, _| content)
488                    .with_cursor_style(CursorStyle::PointingHand)
489                    .on_click(MouseButton::Left, move |_, cx| {
490                        cx.dispatch_action(JoinProject {
491                            project_id,
492                            follow_user_id: user_id,
493                        })
494                    })
495                    .with_tooltip::<JoinProject, _>(
496                        peer_id.0 as usize,
497                        format!("Follow {} into external project", peer_github_login),
498                        Some(Box::new(FollowNextCollaborator)),
499                        theme.tooltip.clone(),
500                        cx,
501                    )
502                    .boxed()
503            } else {
504                content
505            }
506        } else {
507            content
508        }
509    }
510
511    fn render_connection_status(
512        &self,
513        workspace: &ViewHandle<Workspace>,
514        cx: &mut RenderContext<Self>,
515    ) -> Option<ElementBox> {
516        let theme = &cx.global::<Settings>().theme;
517        match &*workspace.read(cx).client().status().borrow() {
518            client::Status::ConnectionError
519            | client::Status::ConnectionLost
520            | client::Status::Reauthenticating { .. }
521            | client::Status::Reconnecting { .. }
522            | client::Status::ReconnectionError { .. } => Some(
523                Container::new(
524                    Align::new(
525                        ConstrainedBox::new(
526                            Svg::new("icons/cloud_slash_12.svg")
527                                .with_color(theme.workspace.titlebar.offline_icon.color)
528                                .boxed(),
529                        )
530                        .with_width(theme.workspace.titlebar.offline_icon.width)
531                        .boxed(),
532                    )
533                    .boxed(),
534                )
535                .with_style(theme.workspace.titlebar.offline_icon.container)
536                .boxed(),
537            ),
538            client::Status::UpgradeRequired => Some(
539                Label::new(
540                    "Please update Zed to collaborate".to_string(),
541                    theme.workspace.titlebar.outdated_warning.text.clone(),
542                )
543                .contained()
544                .with_style(theme.workspace.titlebar.outdated_warning.container)
545                .aligned()
546                .boxed(),
547            ),
548            _ => None,
549        }
550    }
551}
552
553pub struct AvatarRibbon {
554    color: Color,
555}
556
557impl AvatarRibbon {
558    pub fn new(color: Color) -> AvatarRibbon {
559        AvatarRibbon { color }
560    }
561}
562
563impl Element for AvatarRibbon {
564    type LayoutState = ();
565
566    type PaintState = ();
567
568    fn layout(
569        &mut self,
570        constraint: gpui::SizeConstraint,
571        _: &mut gpui::LayoutContext,
572    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
573        (constraint.max, ())
574    }
575
576    fn paint(
577        &mut self,
578        bounds: gpui::geometry::rect::RectF,
579        _: gpui::geometry::rect::RectF,
580        _: &mut Self::LayoutState,
581        cx: &mut gpui::PaintContext,
582    ) -> Self::PaintState {
583        let mut path = PathBuilder::new();
584        path.reset(bounds.lower_left());
585        path.curve_to(
586            bounds.origin() + vec2f(bounds.height(), 0.),
587            bounds.origin(),
588        );
589        path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
590        path.curve_to(bounds.lower_right(), bounds.upper_right());
591        path.line_to(bounds.lower_left());
592        cx.scene.push_path(path.build(self.color, None));
593    }
594
595    fn rect_for_text_range(
596        &self,
597        _: Range<usize>,
598        _: RectF,
599        _: RectF,
600        _: &Self::LayoutState,
601        _: &Self::PaintState,
602        _: &gpui::MeasurementContext,
603    ) -> Option<RectF> {
604        None
605    }
606
607    fn debug(
608        &self,
609        bounds: gpui::geometry::rect::RectF,
610        _: &Self::LayoutState,
611        _: &Self::PaintState,
612        _: &gpui::DebugContext,
613    ) -> gpui::json::Value {
614        json::json!({
615            "type": "AvatarRibbon",
616            "bounds": bounds.to_json(),
617            "color": self.color.to_json(),
618        })
619    }
620}