collab.rs

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