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}