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}