collab.rs

  1use std::rc::Rc;
  2use std::sync::Arc;
  3
  4use call::{ActiveCall, ParticipantLocation, Room};
  5use client::{User, proto::PeerId};
  6use gpui::{
  7    AnyElement, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, WeakEntity,
  8    canvas, point,
  9};
 10use gpui::{App, Task, Window, actions};
 11use rpc::proto::{self};
 12use theme::ActiveTheme;
 13use ui::{
 14    Avatar, AvatarAudioStatusIndicator, ContextMenu, ContextMenuItem, Divider, DividerColor,
 15    Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*,
 16};
 17use util::maybe;
 18use workspace::notifications::DetachAndPromptErr;
 19
 20use crate::TitleBar;
 21
 22actions!(
 23    collab,
 24    [
 25        /// Toggles screen sharing on or off.
 26        ToggleScreenSharing,
 27        /// Toggles microphone mute.
 28        ToggleMute,
 29        /// Toggles deafen mode (mute both microphone and speakers).
 30        ToggleDeafen
 31    ]
 32);
 33
 34fn toggle_screen_sharing(
 35    screen: Option<Rc<dyn ScreenCaptureSource>>,
 36    window: &mut Window,
 37    cx: &mut App,
 38) {
 39    let call = ActiveCall::global(cx).read(cx);
 40    if let Some(room) = call.room().cloned() {
 41        let toggle_screen_sharing = room.update(cx, |room, cx| {
 42            let clicked_on_currently_shared_screen =
 43                room.shared_screen_id().is_some_and(|screen_id| {
 44                    Some(screen_id)
 45                        == screen
 46                            .as_deref()
 47                            .and_then(|s| s.metadata().ok().map(|meta| meta.id))
 48                });
 49            let should_unshare_current_screen = room.is_sharing_screen();
 50            let unshared_current_screen = should_unshare_current_screen.then(|| {
 51                telemetry::event!(
 52                    "Screen Share Disabled",
 53                    room_id = room.id(),
 54                    channel_id = room.channel_id(),
 55                );
 56                room.unshare_screen(clicked_on_currently_shared_screen || screen.is_none(), cx)
 57            });
 58            if let Some(screen) = screen {
 59                if !should_unshare_current_screen {
 60                    telemetry::event!(
 61                        "Screen Share Enabled",
 62                        room_id = room.id(),
 63                        channel_id = room.channel_id(),
 64                    );
 65                }
 66                cx.spawn(async move |room, cx| {
 67                    unshared_current_screen.transpose()?;
 68                    if !clicked_on_currently_shared_screen {
 69                        room.update(cx, |room, cx| room.share_screen(screen, cx))?
 70                            .await
 71                    } else {
 72                        Ok(())
 73                    }
 74                })
 75            } else {
 76                Task::ready(Ok(()))
 77            }
 78        });
 79        toggle_screen_sharing.detach_and_prompt_err("Sharing Screen Failed", window, cx, |e, _, _| Some(format!("{:?}\n\nPlease check that you have given Zed permissions to record your screen in Settings.", e)));
 80    }
 81}
 82
 83fn toggle_mute(_: &ToggleMute, cx: &mut App) {
 84    let call = ActiveCall::global(cx).read(cx);
 85    if let Some(room) = call.room().cloned() {
 86        room.update(cx, |room, cx| {
 87            let operation = if room.is_muted() {
 88                "Microphone Enabled"
 89            } else {
 90                "Microphone Disabled"
 91            };
 92            telemetry::event!(
 93                operation,
 94                room_id = room.id(),
 95                channel_id = room.channel_id(),
 96            );
 97
 98            room.toggle_mute(cx)
 99        });
100    }
101}
102
103fn toggle_deafen(_: &ToggleDeafen, cx: &mut App) {
104    if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
105        room.update(cx, |room, cx| room.toggle_deafen(cx));
106    }
107}
108
109fn render_color_ribbon(color: Hsla) -> impl Element {
110    canvas(
111        move |_, _, _| {},
112        move |bounds, _, window, _| {
113            let height = bounds.size.height;
114            let horizontal_offset = height;
115            let vertical_offset = px(height.0 / 2.0);
116            let mut path = Path::new(bounds.bottom_left());
117            path.curve_to(
118                bounds.origin + point(horizontal_offset, vertical_offset),
119                bounds.origin + point(px(0.0), vertical_offset),
120            );
121            path.line_to(bounds.top_right() + point(-horizontal_offset, vertical_offset));
122            path.curve_to(
123                bounds.bottom_right(),
124                bounds.top_right() + point(px(0.0), vertical_offset),
125            );
126            path.line_to(bounds.bottom_left());
127            window.paint_path(path, color);
128        },
129    )
130    .h_1()
131    .w_full()
132}
133
134impl TitleBar {
135    pub(crate) fn render_collaborator_list(
136        &self,
137        _: &mut Window,
138        cx: &mut Context<Self>,
139    ) -> impl IntoElement {
140        let room = ActiveCall::global(cx).read(cx).room().cloned();
141        let current_user = self.user_store.read(cx).current_user();
142        let client = self.client.clone();
143        let project_id = self.project.read(cx).remote_id();
144        let workspace = self.workspace.upgrade();
145
146        h_flex()
147            .id("collaborator-list")
148            .w_full()
149            .gap_1()
150            .overflow_x_scroll()
151            .when_some(
152                current_user.clone().zip(client.peer_id()).zip(room.clone()),
153                |this, ((current_user, peer_id), room)| {
154                    let player_colors = cx.theme().players();
155                    let room = room.read(cx);
156                    let mut remote_participants =
157                        room.remote_participants().values().collect::<Vec<_>>();
158                    remote_participants.sort_by_key(|p| p.participant_index.0);
159
160                    let current_user_face_pile = self.render_collaborator(
161                        &current_user,
162                        peer_id,
163                        true,
164                        room.is_speaking(),
165                        room.is_muted(),
166                        None,
167                        room,
168                        project_id,
169                        &current_user,
170                        cx,
171                    );
172
173                    this.children(current_user_face_pile.map(|face_pile| {
174                        v_flex()
175                            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
176                            .child(face_pile)
177                            .child(render_color_ribbon(player_colors.local().cursor))
178                    }))
179                    .children(remote_participants.iter().filter_map(|collaborator| {
180                        let player_color =
181                            player_colors.color_for_participant(collaborator.participant_index.0);
182                        let is_following = workspace
183                            .as_ref()?
184                            .read(cx)
185                            .is_being_followed(collaborator.peer_id);
186                        let is_present = project_id.map_or(false, |project_id| {
187                            collaborator.location
188                                == ParticipantLocation::SharedProject { project_id }
189                        });
190
191                        let facepile = self.render_collaborator(
192                            &collaborator.user,
193                            collaborator.peer_id,
194                            is_present,
195                            collaborator.speaking,
196                            collaborator.muted,
197                            is_following.then_some(player_color.selection),
198                            room,
199                            project_id,
200                            &current_user,
201                            cx,
202                        )?;
203
204                        Some(
205                            v_flex()
206                                .id(("collaborator", collaborator.user.id))
207                                .child(facepile)
208                                .child(render_color_ribbon(player_color.cursor))
209                                .cursor_pointer()
210                                .on_click({
211                                    let peer_id = collaborator.peer_id;
212                                    cx.listener(move |this, _, window, cx| {
213                                        this.workspace
214                                            .update(cx, |workspace, cx| {
215                                                if is_following {
216                                                    workspace.unfollow(peer_id, window, cx);
217                                                } else {
218                                                    workspace.follow(peer_id, window, cx);
219                                                }
220                                            })
221                                            .ok();
222                                    })
223                                })
224                                .tooltip({
225                                    let login = collaborator.user.github_login.clone();
226                                    Tooltip::text(format!("Follow {login}"))
227                                }),
228                        )
229                    }))
230                },
231            )
232    }
233
234    fn render_collaborator(
235        &self,
236        user: &Arc<User>,
237        peer_id: PeerId,
238        is_present: bool,
239        is_speaking: bool,
240        is_muted: bool,
241        leader_selection_color: Option<Hsla>,
242        room: &Room,
243        project_id: Option<u64>,
244        current_user: &Arc<User>,
245        cx: &App,
246    ) -> Option<Div> {
247        if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) {
248            return None;
249        }
250
251        const FACEPILE_LIMIT: usize = 3;
252        let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id));
253        let extra_count = followers.len().saturating_sub(FACEPILE_LIMIT);
254
255        Some(
256            div()
257                .m_0p5()
258                .p_0p5()
259                // When the collaborator is not followed, still draw this wrapper div, but leave
260                // it transparent, so that it does not shift the layout when following.
261                .when_some(leader_selection_color, |div, color| {
262                    div.rounded_sm().bg(color)
263                })
264                .child(
265                    Facepile::empty()
266                        .child(
267                            Avatar::new(user.avatar_uri.clone())
268                                .grayscale(!is_present)
269                                .border_color(if is_speaking {
270                                    cx.theme().status().info
271                                } else {
272                                    // We draw the border in a transparent color rather to avoid
273                                    // the layout shift that would come with adding/removing the border.
274                                    gpui::transparent_black()
275                                })
276                                .when(is_muted, |avatar| {
277                                    avatar.indicator(
278                                        AvatarAudioStatusIndicator::new(ui::AudioStatus::Muted)
279                                            .tooltip({
280                                                let github_login = user.github_login.clone();
281                                                Tooltip::text(format!("{} is muted", github_login))
282                                            }),
283                                    )
284                                }),
285                        )
286                        .children(followers.iter().take(FACEPILE_LIMIT).filter_map(
287                            |follower_peer_id| {
288                                let follower = room
289                                    .remote_participants()
290                                    .values()
291                                    .find_map(|p| {
292                                        (p.peer_id == *follower_peer_id).then_some(&p.user)
293                                    })
294                                    .or_else(|| {
295                                        (self.client.peer_id() == Some(*follower_peer_id))
296                                            .then_some(current_user)
297                                    })?
298                                    .clone();
299
300                                Some(div().mt(-px(4.)).child(
301                                    Avatar::new(follower.avatar_uri.clone()).size(rems(0.75)),
302                                ))
303                            },
304                        ))
305                        .children(if extra_count > 0 {
306                            Some(
307                                Label::new(format!("+{extra_count}"))
308                                    .ml_1()
309                                    .into_any_element(),
310                            )
311                        } else {
312                            None
313                        }),
314                ),
315        )
316    }
317
318    pub(crate) fn render_call_controls(
319        &self,
320        window: &mut Window,
321        cx: &mut Context<Self>,
322    ) -> Vec<AnyElement> {
323        let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
324            return Vec::new();
325        };
326
327        let is_connecting_to_project = self
328            .workspace
329            .update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
330            .unwrap_or(false);
331
332        let room = room.read(cx);
333        let project = self.project.read(cx);
334        let is_local = project.is_local() || project.is_via_ssh();
335        let is_shared = is_local && project.is_shared();
336        let is_muted = room.is_muted();
337        let muted_by_user = room.muted_by_user();
338        let is_deafened = room.is_deafened().unwrap_or(false);
339        let is_screen_sharing = room.is_sharing_screen();
340        let can_use_microphone = room.can_use_microphone();
341        let can_share_projects = room.can_share_projects();
342        let screen_sharing_supported = cx.is_screen_capture_supported();
343
344        let mut children = Vec::new();
345
346        children.push(
347            h_flex()
348                .gap_1()
349                .child(
350                    IconButton::new("leave-call", IconName::Exit)
351                        .style(ButtonStyle::Subtle)
352                        .tooltip(Tooltip::text("Leave Call"))
353                        .icon_size(IconSize::Small)
354                        .on_click(move |_, _window, cx| {
355                            ActiveCall::global(cx)
356                                .update(cx, |call, cx| call.hang_up(cx))
357                                .detach_and_log_err(cx);
358                        }),
359                )
360                .child(Divider::vertical().color(DividerColor::Border))
361                .into_any_element(),
362        );
363
364        if is_local && can_share_projects && !is_connecting_to_project {
365            children.push(
366                Button::new(
367                    "toggle_sharing",
368                    if is_shared { "Unshare" } else { "Share" },
369                )
370                .tooltip(Tooltip::text(if is_shared {
371                    "Stop sharing project with call participants"
372                } else {
373                    "Share project with call participants"
374                }))
375                .style(ButtonStyle::Subtle)
376                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
377                .toggle_state(is_shared)
378                .label_size(LabelSize::Small)
379                .on_click(cx.listener(move |this, _, window, cx| {
380                    if is_shared {
381                        this.unshare_project(window, cx);
382                    } else {
383                        this.share_project(cx);
384                    }
385                }))
386                .into_any_element(),
387            );
388        }
389
390        if can_use_microphone {
391            children.push(
392                IconButton::new(
393                    "mute-microphone",
394                    if is_muted {
395                        IconName::MicMute
396                    } else {
397                        IconName::Mic
398                    },
399                )
400                .tooltip(move |window, cx| {
401                    if is_muted {
402                        if is_deafened {
403                            Tooltip::with_meta(
404                                "Unmute Microphone",
405                                None,
406                                "Audio will be unmuted",
407                                window,
408                                cx,
409                            )
410                        } else {
411                            Tooltip::simple("Unmute Microphone", cx)
412                        }
413                    } else {
414                        Tooltip::simple("Mute Microphone", cx)
415                    }
416                })
417                .style(ButtonStyle::Subtle)
418                .icon_size(IconSize::Small)
419                .toggle_state(is_muted)
420                .selected_style(ButtonStyle::Tinted(TintColor::Error))
421                .on_click(move |_, _window, cx| {
422                    toggle_mute(&Default::default(), cx);
423                })
424                .into_any_element(),
425            );
426        }
427
428        children.push(
429            IconButton::new(
430                "mute-sound",
431                if is_deafened {
432                    IconName::AudioOff
433                } else {
434                    IconName::AudioOn
435                },
436            )
437            .style(ButtonStyle::Subtle)
438            .selected_style(ButtonStyle::Tinted(TintColor::Error))
439            .icon_size(IconSize::Small)
440            .toggle_state(is_deafened)
441            .tooltip(move |window, cx| {
442                if is_deafened {
443                    let label = "Unmute Audio";
444
445                    if !muted_by_user {
446                        Tooltip::with_meta(label, None, "Microphone will be unmuted", window, cx)
447                    } else {
448                        Tooltip::simple(label, cx)
449                    }
450                } else {
451                    let label = "Mute Audio";
452
453                    if !muted_by_user {
454                        Tooltip::with_meta(label, None, "Microphone will be muted", window, cx)
455                    } else {
456                        Tooltip::simple(label, cx)
457                    }
458                }
459            })
460            .on_click(move |_, _, cx| toggle_deafen(&Default::default(), cx))
461            .into_any_element(),
462        );
463
464        if can_use_microphone && screen_sharing_supported {
465            let trigger = IconButton::new("screen-share", IconName::Screen)
466                .style(ButtonStyle::Subtle)
467                .icon_size(IconSize::Small)
468                .toggle_state(is_screen_sharing)
469                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
470                .tooltip(Tooltip::text(if is_screen_sharing {
471                    "Stop Sharing Screen"
472                } else {
473                    "Share Screen"
474                }))
475                .on_click(move |_, window, cx| {
476                    let should_share = ActiveCall::global(cx)
477                        .read(cx)
478                        .room()
479                        .is_some_and(|room| !room.read(cx).is_sharing_screen());
480
481                    window
482                        .spawn(cx, async move |cx| {
483                            let screen = if should_share {
484                                cx.update(|_, cx| pick_default_screen(cx))?.await
485                            } else {
486                                None
487                            };
488
489                            cx.update(|window, cx| toggle_screen_sharing(screen, window, cx))?;
490
491                            Result::<_, anyhow::Error>::Ok(())
492                        })
493                        .detach();
494                });
495
496            children.push(
497                SplitButton::new(
498                    trigger.render(window, cx),
499                    self.render_screen_list().into_any_element(),
500                )
501                .style(SplitButtonStyle::Transparent)
502                .into_any_element(),
503            );
504        }
505
506        children.push(div().pr_2().into_any_element());
507
508        children
509    }
510
511    fn render_screen_list(&self) -> impl IntoElement {
512        PopoverMenu::new("screen-share-screen-list")
513            .with_handle(self.screen_share_popover_handle.clone())
514            .trigger(
515                ui::ButtonLike::new_rounded_right("screen-share-screen-list-trigger")
516                    .child(
517                        h_flex()
518                            .mx_neg_0p5()
519                            .h_full()
520                            .justify_center()
521                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
522                    )
523                    .toggle_state(self.screen_share_popover_handle.is_deployed()),
524            )
525            .menu(|window, cx| {
526                let screens = cx.screen_capture_sources();
527                Some(ContextMenu::build(window, cx, |context_menu, _, cx| {
528                    cx.spawn(async move |this: WeakEntity<ContextMenu>, cx| {
529                        let screens = screens.await??;
530                        this.update(cx, |this, cx| {
531                            let active_screenshare_id = ActiveCall::global(cx)
532                                .read(cx)
533                                .room()
534                                .and_then(|room| room.read(cx).shared_screen_id());
535                            for screen in screens {
536                                let Ok(meta) = screen.metadata() else {
537                                    continue;
538                                };
539
540                                let label = meta
541                                    .label
542                                    .clone()
543                                    .unwrap_or_else(|| SharedString::from("Unknown screen"));
544                                let resolution = SharedString::from(format!(
545                                    "{} × {}",
546                                    meta.resolution.width.0, meta.resolution.height.0
547                                ));
548                                this.push_item(ContextMenuItem::CustomEntry {
549                                    entry_render: Box::new(move |_, _| {
550                                        h_flex()
551                                            .gap_2()
552                                            .child(
553                                                Icon::new(IconName::Screen)
554                                                    .size(IconSize::XSmall)
555                                                    .map(|this| {
556                                                        if active_screenshare_id == Some(meta.id) {
557                                                            this.color(Color::Accent)
558                                                        } else {
559                                                            this.color(Color::Muted)
560                                                        }
561                                                    }),
562                                            )
563                                            .child(Label::new(label.clone()))
564                                            .child(
565                                                Label::new(resolution.clone())
566                                                    .color(Color::Muted)
567                                                    .size(LabelSize::Small),
568                                            )
569                                            .into_any()
570                                    }),
571                                    selectable: true,
572                                    documentation_aside: None,
573                                    handler: Rc::new(move |_, window, cx| {
574                                        toggle_screen_sharing(Some(screen.clone()), window, cx);
575                                    }),
576                                });
577                            }
578                        })
579                    })
580                    .detach_and_log_err(cx);
581                    context_menu
582                }))
583            })
584    }
585}
586
587/// Picks the screen to share when clicking on the main screen sharing button.
588fn pick_default_screen(cx: &App) -> Task<Option<Rc<dyn ScreenCaptureSource>>> {
589    let source = cx.screen_capture_sources();
590    cx.spawn(async move |_| {
591        let available_sources = maybe!(async move { source.await? }).await.ok()?;
592        available_sources
593            .iter()
594            .find(|it| {
595                it.as_ref()
596                    .metadata()
597                    .is_ok_and(|meta| meta.is_main.unwrap_or_default())
598            })
599            .or_else(|| available_sources.iter().next())
600            .cloned()
601    })
602}