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