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