collab.rs

  1use std::rc::Rc;
  2use std::sync::Arc;
  3
  4use call::{ActiveCall, ParticipantLocation, 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::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                                .tooltip({
230                                    let login = collaborator.user.github_login.clone();
231                                    Tooltip::text(format!("Follow {login}"))
232                                }),
233                        )
234                    }))
235                },
236            )
237    }
238
239    fn render_collaborator(
240        &self,
241        user: &Arc<User>,
242        peer_id: PeerId,
243        is_present: bool,
244        is_speaking: bool,
245        is_muted: bool,
246        leader_selection_color: Option<Hsla>,
247        room: &Room,
248        project_id: Option<u64>,
249        current_user: &Arc<User>,
250        cx: &App,
251    ) -> Option<Div> {
252        if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) {
253            return None;
254        }
255
256        const FACEPILE_LIMIT: usize = 3;
257        let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id));
258        let extra_count = followers.len().saturating_sub(FACEPILE_LIMIT);
259
260        Some(
261            div()
262                .m_0p5()
263                .p_0p5()
264                // When the collaborator is not followed, still draw this wrapper div, but leave
265                // it transparent, so that it does not shift the layout when following.
266                .when_some(leader_selection_color, |div, color| {
267                    div.rounded_sm().bg(color)
268                })
269                .child(
270                    Facepile::empty()
271                        .child(
272                            Avatar::new(user.avatar_uri.clone())
273                                .grayscale(!is_present)
274                                .border_color(if is_speaking {
275                                    cx.theme().status().info
276                                } else {
277                                    // We draw the border in a transparent color rather to avoid
278                                    // the layout shift that would come with adding/removing the border.
279                                    gpui::transparent_black()
280                                })
281                                .when(is_muted, |avatar| {
282                                    avatar.indicator(
283                                        AvatarAudioStatusIndicator::new(ui::AudioStatus::Muted)
284                                            .tooltip({
285                                                let github_login = user.github_login.clone();
286                                                Tooltip::text(format!("{} is muted", github_login))
287                                            }),
288                                    )
289                                }),
290                        )
291                        .children(followers.iter().take(FACEPILE_LIMIT).filter_map(
292                            |follower_peer_id| {
293                                let follower = room
294                                    .remote_participants()
295                                    .values()
296                                    .find_map(|p| {
297                                        (p.peer_id == *follower_peer_id).then_some(&p.user)
298                                    })
299                                    .or_else(|| {
300                                        (self.client.peer_id() == Some(*follower_peer_id))
301                                            .then_some(current_user)
302                                    })?
303                                    .clone();
304
305                                Some(div().mt(-px(4.)).child(
306                                    Avatar::new(follower.avatar_uri.clone()).size(rems(0.75)),
307                                ))
308                            },
309                        ))
310                        .children(if extra_count > 0 {
311                            Some(
312                                Label::new(format!("+{extra_count}"))
313                                    .ml_1()
314                                    .into_any_element(),
315                            )
316                        } else {
317                            None
318                        }),
319                ),
320        )
321    }
322
323    pub(crate) fn render_call_controls(
324        &self,
325        window: &mut Window,
326        cx: &mut Context<Self>,
327    ) -> Vec<AnyElement> {
328        let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
329            return Vec::new();
330        };
331
332        let is_connecting_to_project = self
333            .workspace
334            .update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
335            .unwrap_or(false);
336
337        let room = room.read(cx);
338        let project = self.project.read(cx);
339        let is_local = project.is_local() || project.is_via_remote_server();
340        let is_shared = is_local && project.is_shared();
341        let is_muted = room.is_muted();
342        let muted_by_user = room.muted_by_user();
343        let is_deafened = room.is_deafened().unwrap_or(false);
344        let is_screen_sharing = room.is_sharing_screen();
345        let can_use_microphone = room.can_use_microphone();
346        let can_share_projects = room.can_share_projects();
347        let screen_sharing_supported = cx.is_screen_capture_supported();
348
349        let channel_store = ChannelStore::global(cx);
350        let channel = room
351            .channel_id()
352            .and_then(|channel_id| channel_store.read(cx).channel_for_id(channel_id).cloned());
353
354        let mut children = Vec::new();
355
356        children.push(
357            h_flex()
358                .gap_1()
359                .child(
360                    IconButton::new("leave-call", IconName::Exit)
361                        .style(ButtonStyle::Subtle)
362                        .tooltip(Tooltip::text("Leave Call"))
363                        .icon_size(IconSize::Small)
364                        .on_click(move |_, _window, cx| {
365                            ActiveCall::global(cx)
366                                .update(cx, |call, cx| call.hang_up(cx))
367                                .detach_and_log_err(cx);
368                        }),
369                )
370                .child(Divider::vertical().color(DividerColor::Border))
371                .into_any_element(),
372        );
373
374        if is_local && can_share_projects && !is_connecting_to_project {
375            let is_sharing_disabled = channel.is_some_and(|channel| match channel.visibility {
376                proto::ChannelVisibility::Public => project.visible_worktrees(cx).any(|worktree| {
377                    let worktree_id = worktree.read(cx).id();
378
379                    let settings_location = Some(SettingsLocation {
380                        worktree_id,
381                        path: RelPath::empty(),
382                    });
383
384                    WorktreeSettings::get(settings_location, cx).prevent_sharing_in_public_channels
385                }),
386                proto::ChannelVisibility::Members => false,
387            });
388
389            children.push(
390                Button::new(
391                    "toggle_sharing",
392                    if is_shared { "Unshare" } else { "Share" },
393                )
394                .tooltip(Tooltip::text(if is_shared {
395                    "Stop sharing project with call participants"
396                } else {
397                    "Share project with call participants"
398                }))
399                .style(ButtonStyle::Subtle)
400                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
401                .toggle_state(is_shared)
402                .label_size(LabelSize::Small)
403                .when(is_sharing_disabled, |parent| {
404                    parent.disabled(true).tooltip(Tooltip::text(
405                        "This project may not be shared in a public channel.",
406                    ))
407                })
408                .on_click(cx.listener(move |this, _, window, cx| {
409                    if is_shared {
410                        this.unshare_project(window, cx);
411                    } else {
412                        this.share_project(cx);
413                    }
414                }))
415                .into_any_element(),
416            );
417        }
418
419        if can_use_microphone {
420            children.push(
421                IconButton::new(
422                    "mute-microphone",
423                    if is_muted {
424                        IconName::MicMute
425                    } else {
426                        IconName::Mic
427                    },
428                )
429                .tooltip(move |_window, cx| {
430                    if is_muted {
431                        if is_deafened {
432                            Tooltip::with_meta(
433                                "Unmute Microphone",
434                                None,
435                                "Audio will be unmuted",
436                                cx,
437                            )
438                        } else {
439                            Tooltip::simple("Unmute Microphone", cx)
440                        }
441                    } else {
442                        Tooltip::simple("Mute Microphone", cx)
443                    }
444                })
445                .style(ButtonStyle::Subtle)
446                .icon_size(IconSize::Small)
447                .toggle_state(is_muted)
448                .selected_style(ButtonStyle::Tinted(TintColor::Error))
449                .on_click(move |_, _window, cx| toggle_mute(cx))
450                .into_any_element(),
451            );
452        }
453
454        children.push(
455            IconButton::new(
456                "mute-sound",
457                if is_deafened {
458                    IconName::AudioOff
459                } else {
460                    IconName::AudioOn
461                },
462            )
463            .style(ButtonStyle::Subtle)
464            .selected_style(ButtonStyle::Tinted(TintColor::Error))
465            .icon_size(IconSize::Small)
466            .toggle_state(is_deafened)
467            .tooltip(move |_window, cx| {
468                if is_deafened {
469                    let label = "Unmute Audio";
470
471                    if !muted_by_user {
472                        Tooltip::with_meta(label, None, "Microphone will be unmuted", cx)
473                    } else {
474                        Tooltip::simple(label, cx)
475                    }
476                } else {
477                    let label = "Mute Audio";
478
479                    if !muted_by_user {
480                        Tooltip::with_meta(label, None, "Microphone will be muted", cx)
481                    } else {
482                        Tooltip::simple(label, cx)
483                    }
484                }
485            })
486            .on_click(move |_, _, cx| toggle_deafen(cx))
487            .into_any_element(),
488        );
489
490        if can_use_microphone && screen_sharing_supported {
491            let trigger = IconButton::new("screen-share", IconName::Screen)
492                .style(ButtonStyle::Subtle)
493                .icon_size(IconSize::Small)
494                .toggle_state(is_screen_sharing)
495                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
496                .tooltip(Tooltip::text(if is_screen_sharing {
497                    "Stop Sharing Screen"
498                } else {
499                    "Share Screen"
500                }))
501                .on_click(move |_, window, cx| {
502                    let should_share = ActiveCall::global(cx)
503                        .read(cx)
504                        .room()
505                        .is_some_and(|room| !room.read(cx).is_sharing_screen());
506
507                    window
508                        .spawn(cx, async move |cx| {
509                            let screen = if should_share {
510                                cx.update(|_, cx| pick_default_screen(cx))?.await
511                            } else {
512                                Ok(None)
513                            };
514                            cx.update(|window, cx| toggle_screen_sharing(screen, window, cx))?;
515
516                            Result::<_, anyhow::Error>::Ok(())
517                        })
518                        .detach();
519                });
520
521            children.push(
522                SplitButton::new(
523                    trigger.render(window, cx),
524                    self.render_screen_list().into_any_element(),
525                )
526                .style(SplitButtonStyle::Transparent)
527                .into_any_element(),
528            );
529        }
530
531        children.push(div().pr_2().into_any_element());
532
533        children
534    }
535
536    fn render_screen_list(&self) -> impl IntoElement {
537        PopoverMenu::new("screen-share-screen-list")
538            .with_handle(self.screen_share_popover_handle.clone())
539            .trigger(
540                ui::ButtonLike::new_rounded_right("screen-share-screen-list-trigger")
541                    .child(
542                        h_flex()
543                            .mx_neg_0p5()
544                            .h_full()
545                            .justify_center()
546                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
547                    )
548                    .toggle_state(self.screen_share_popover_handle.is_deployed()),
549            )
550            .menu(|window, cx| {
551                let screens = cx.screen_capture_sources();
552                Some(ContextMenu::build(window, cx, |context_menu, _, cx| {
553                    cx.spawn(async move |this: WeakEntity<ContextMenu>, cx| {
554                        let screens = screens.await??;
555                        this.update(cx, |this, cx| {
556                            let active_screenshare_id = ActiveCall::global(cx)
557                                .read(cx)
558                                .room()
559                                .and_then(|room| room.read(cx).shared_screen_id());
560                            for screen in screens {
561                                let Ok(meta) = screen.metadata() else {
562                                    continue;
563                                };
564
565                                let label = meta
566                                    .label
567                                    .clone()
568                                    .unwrap_or_else(|| SharedString::from("Unknown screen"));
569                                let resolution = SharedString::from(format!(
570                                    "{} × {}",
571                                    meta.resolution.width.0, meta.resolution.height.0
572                                ));
573                                this.push_item(ContextMenuItem::CustomEntry {
574                                    entry_render: Box::new(move |_, _| {
575                                        h_flex()
576                                            .gap_2()
577                                            .child(
578                                                Icon::new(IconName::Screen)
579                                                    .size(IconSize::XSmall)
580                                                    .map(|this| {
581                                                        if active_screenshare_id == Some(meta.id) {
582                                                            this.color(Color::Accent)
583                                                        } else {
584                                                            this.color(Color::Muted)
585                                                        }
586                                                    }),
587                                            )
588                                            .child(Label::new(label.clone()))
589                                            .child(
590                                                Label::new(resolution.clone())
591                                                    .color(Color::Muted)
592                                                    .size(LabelSize::Small),
593                                            )
594                                            .into_any()
595                                    }),
596                                    selectable: true,
597                                    documentation_aside: None,
598                                    handler: Rc::new(move |_, window, cx| {
599                                        toggle_screen_sharing(Ok(Some(screen.clone())), window, cx);
600                                    }),
601                                });
602                            }
603                        })
604                    })
605                    .detach_and_log_err(cx);
606                    context_menu
607                }))
608            })
609    }
610}
611
612/// Picks the screen to share when clicking on the main screen sharing button.
613fn pick_default_screen(cx: &App) -> Task<anyhow::Result<Option<Rc<dyn ScreenCaptureSource>>>> {
614    let source = cx.screen_capture_sources();
615    cx.spawn(async move |_| {
616        let available_sources = source.await??;
617        Ok(available_sources
618            .iter()
619            .find(|it| {
620                it.as_ref()
621                    .metadata()
622                    .is_ok_and(|meta| meta.is_main.unwrap_or_default())
623            })
624            .or_else(|| available_sources.first())
625            .cloned())
626    })
627}