collab_titlebar_item.rs

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