collab_titlebar_item.rs

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