collab.rs

  1use std::sync::Arc;
  2
  3use call::{ActiveCall, ParticipantLocation, Room};
  4use client::{User, proto::PeerId};
  5use gpui::{AnyElement, Hsla, IntoElement, MouseButton, Path, Styled, canvas, point};
  6use gpui::{App, Task, Window, actions};
  7use rpc::proto::{self};
  8use theme::ActiveTheme;
  9use ui::{Avatar, AvatarAudioStatusIndicator, Facepile, TintColor, Tooltip, prelude::*};
 10use workspace::notifications::DetachAndPromptErr;
 11
 12use crate::TitleBar;
 13
 14actions!(
 15    collab,
 16    [
 17        /// Toggles screen sharing on or off.
 18        ToggleScreenSharing,
 19        /// Toggles microphone mute.
 20        ToggleMute,
 21        /// Toggles deafen mode (mute both microphone and speakers).
 22        ToggleDeafen
 23    ]
 24);
 25
 26fn toggle_screen_sharing(_: &ToggleScreenSharing, window: &mut Window, cx: &mut App) {
 27    let call = ActiveCall::global(cx).read(cx);
 28    if let Some(room) = call.room().cloned() {
 29        let toggle_screen_sharing = room.update(cx, |room, cx| {
 30            if room.is_screen_sharing() {
 31                telemetry::event!(
 32                    "Screen Share Disabled",
 33                    room_id = room.id(),
 34                    channel_id = room.channel_id(),
 35                );
 36                Task::ready(room.unshare_screen(cx))
 37            } else {
 38                telemetry::event!(
 39                    "Screen Share Enabled",
 40                    room_id = room.id(),
 41                    channel_id = room.channel_id(),
 42                );
 43                room.share_screen(cx)
 44            }
 45        });
 46        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)));
 47    }
 48}
 49
 50fn toggle_mute(_: &ToggleMute, cx: &mut App) {
 51    let call = ActiveCall::global(cx).read(cx);
 52    if let Some(room) = call.room().cloned() {
 53        room.update(cx, |room, cx| {
 54            let operation = if room.is_muted() {
 55                "Microphone Enabled"
 56            } else {
 57                "Microphone Disabled"
 58            };
 59            telemetry::event!(
 60                operation,
 61                room_id = room.id(),
 62                channel_id = room.channel_id(),
 63            );
 64
 65            room.toggle_mute(cx)
 66        });
 67    }
 68}
 69
 70fn toggle_deafen(_: &ToggleDeafen, cx: &mut App) {
 71    if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
 72        room.update(cx, |room, cx| room.toggle_deafen(cx));
 73    }
 74}
 75
 76fn render_color_ribbon(color: Hsla) -> impl Element {
 77    canvas(
 78        move |_, _, _| {},
 79        move |bounds, _, window, _| {
 80            let height = bounds.size.height;
 81            let horizontal_offset = height;
 82            let vertical_offset = px(height.0 / 2.0);
 83            let mut path = Path::new(bounds.bottom_left());
 84            path.curve_to(
 85                bounds.origin + point(horizontal_offset, vertical_offset),
 86                bounds.origin + point(px(0.0), vertical_offset),
 87            );
 88            path.line_to(bounds.top_right() + point(-horizontal_offset, vertical_offset));
 89            path.curve_to(
 90                bounds.bottom_right(),
 91                bounds.top_right() + point(px(0.0), vertical_offset),
 92            );
 93            path.line_to(bounds.bottom_left());
 94            window.paint_path(path, color);
 95        },
 96    )
 97    .h_1()
 98    .w_full()
 99}
100
101impl TitleBar {
102    pub(crate) fn render_collaborator_list(
103        &self,
104        _: &mut Window,
105        cx: &mut Context<Self>,
106    ) -> impl IntoElement {
107        let room = ActiveCall::global(cx).read(cx).room().cloned();
108        let current_user = self.user_store.read(cx).current_user();
109        let client = self.client.clone();
110        let project_id = self.project.read(cx).remote_id();
111        let workspace = self.workspace.upgrade();
112
113        h_flex()
114            .id("collaborator-list")
115            .w_full()
116            .gap_1()
117            .overflow_x_scroll()
118            .when_some(
119                current_user.clone().zip(client.peer_id()).zip(room.clone()),
120                |this, ((current_user, peer_id), room)| {
121                    let player_colors = cx.theme().players();
122                    let room = room.read(cx);
123                    let mut remote_participants =
124                        room.remote_participants().values().collect::<Vec<_>>();
125                    remote_participants.sort_by_key(|p| p.participant_index.0);
126
127                    let current_user_face_pile = self.render_collaborator(
128                        &current_user,
129                        peer_id,
130                        true,
131                        room.is_speaking(),
132                        room.is_muted(),
133                        None,
134                        room,
135                        project_id,
136                        &current_user,
137                        cx,
138                    );
139
140                    this.children(current_user_face_pile.map(|face_pile| {
141                        v_flex()
142                            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
143                            .child(face_pile)
144                            .child(render_color_ribbon(player_colors.local().cursor))
145                    }))
146                    .children(remote_participants.iter().filter_map(|collaborator| {
147                        let player_color =
148                            player_colors.color_for_participant(collaborator.participant_index.0);
149                        let is_following = workspace
150                            .as_ref()?
151                            .read(cx)
152                            .is_being_followed(collaborator.peer_id);
153                        let is_present = project_id.map_or(false, |project_id| {
154                            collaborator.location
155                                == ParticipantLocation::SharedProject { project_id }
156                        });
157
158                        let facepile = self.render_collaborator(
159                            &collaborator.user,
160                            collaborator.peer_id,
161                            is_present,
162                            collaborator.speaking,
163                            collaborator.muted,
164                            is_following.then_some(player_color.selection),
165                            room,
166                            project_id,
167                            &current_user,
168                            cx,
169                        )?;
170
171                        Some(
172                            v_flex()
173                                .id(("collaborator", collaborator.user.id))
174                                .child(facepile)
175                                .child(render_color_ribbon(player_color.cursor))
176                                .cursor_pointer()
177                                .on_click({
178                                    let peer_id = collaborator.peer_id;
179                                    cx.listener(move |this, _, window, cx| {
180                                        this.workspace
181                                            .update(cx, |workspace, cx| {
182                                                if is_following {
183                                                    workspace.unfollow(peer_id, window, cx);
184                                                } else {
185                                                    workspace.follow(peer_id, window, cx);
186                                                }
187                                            })
188                                            .ok();
189                                    })
190                                })
191                                .tooltip({
192                                    let login = collaborator.user.github_login.clone();
193                                    Tooltip::text(format!("Follow {login}"))
194                                }),
195                        )
196                    }))
197                },
198            )
199    }
200
201    fn render_collaborator(
202        &self,
203        user: &Arc<User>,
204        peer_id: PeerId,
205        is_present: bool,
206        is_speaking: bool,
207        is_muted: bool,
208        leader_selection_color: Option<Hsla>,
209        room: &Room,
210        project_id: Option<u64>,
211        current_user: &Arc<User>,
212        cx: &App,
213    ) -> Option<Div> {
214        if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) {
215            return None;
216        }
217
218        const FACEPILE_LIMIT: usize = 3;
219        let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id));
220        let extra_count = followers.len().saturating_sub(FACEPILE_LIMIT);
221
222        Some(
223            div()
224                .m_0p5()
225                .p_0p5()
226                // When the collaborator is not followed, still draw this wrapper div, but leave
227                // it transparent, so that it does not shift the layout when following.
228                .when_some(leader_selection_color, |div, color| {
229                    div.rounded_sm().bg(color)
230                })
231                .child(
232                    Facepile::empty()
233                        .child(
234                            Avatar::new(user.avatar_uri.clone())
235                                .grayscale(!is_present)
236                                .border_color(if is_speaking {
237                                    cx.theme().status().info
238                                } else {
239                                    // We draw the border in a transparent color rather to avoid
240                                    // the layout shift that would come with adding/removing the border.
241                                    gpui::transparent_black()
242                                })
243                                .when(is_muted, |avatar| {
244                                    avatar.indicator(
245                                        AvatarAudioStatusIndicator::new(ui::AudioStatus::Muted)
246                                            .tooltip({
247                                                let github_login = user.github_login.clone();
248                                                Tooltip::text(format!("{} is muted", github_login))
249                                            }),
250                                    )
251                                }),
252                        )
253                        .children(followers.iter().take(FACEPILE_LIMIT).filter_map(
254                            |follower_peer_id| {
255                                let follower = room
256                                    .remote_participants()
257                                    .values()
258                                    .find_map(|p| {
259                                        (p.peer_id == *follower_peer_id).then_some(&p.user)
260                                    })
261                                    .or_else(|| {
262                                        (self.client.peer_id() == Some(*follower_peer_id))
263                                            .then_some(current_user)
264                                    })?
265                                    .clone();
266
267                                Some(div().mt(-px(4.)).child(
268                                    Avatar::new(follower.avatar_uri.clone()).size(rems(0.75)),
269                                ))
270                            },
271                        ))
272                        .children(if extra_count > 0 {
273                            Some(
274                                Label::new(format!("+{extra_count}"))
275                                    .ml_1()
276                                    .into_any_element(),
277                            )
278                        } else {
279                            None
280                        }),
281                ),
282        )
283    }
284
285    pub(crate) fn render_call_controls(
286        &self,
287        window: &mut Window,
288        cx: &mut Context<Self>,
289    ) -> Vec<AnyElement> {
290        let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
291            return Vec::new();
292        };
293
294        let is_connecting_to_project = self
295            .workspace
296            .update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
297            .unwrap_or(false);
298
299        let room = room.read(cx);
300        let project = self.project.read(cx);
301        let is_local = project.is_local() || project.is_via_ssh();
302        let is_shared = is_local && project.is_shared();
303        let is_muted = room.is_muted();
304        let muted_by_user = room.muted_by_user();
305        let is_deafened = room.is_deafened().unwrap_or(false);
306        let is_screen_sharing = room.is_screen_sharing();
307        let can_use_microphone = room.can_use_microphone();
308        let can_share_projects = room.can_share_projects();
309        let screen_sharing_supported = cx.is_screen_capture_supported();
310
311        let mut children = Vec::new();
312
313        if is_local && can_share_projects && !is_connecting_to_project {
314            children.push(
315                Button::new(
316                    "toggle_sharing",
317                    if is_shared { "Unshare" } else { "Share" },
318                )
319                .tooltip(Tooltip::text(if is_shared {
320                    "Stop sharing project with call participants"
321                } else {
322                    "Share project with call participants"
323                }))
324                .style(ButtonStyle::Subtle)
325                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
326                .toggle_state(is_shared)
327                .label_size(LabelSize::Small)
328                .on_click(cx.listener(move |this, _, window, cx| {
329                    if is_shared {
330                        this.unshare_project(window, cx);
331                    } else {
332                        this.share_project(cx);
333                    }
334                }))
335                .into_any_element(),
336            );
337        }
338
339        children.push(
340            div()
341                .pr_2()
342                .child(
343                    IconButton::new("leave-call", ui::IconName::Exit)
344                        .style(ButtonStyle::Subtle)
345                        .tooltip(Tooltip::text("Leave call"))
346                        .icon_size(IconSize::Small)
347                        .on_click(move |_, _window, cx| {
348                            ActiveCall::global(cx)
349                                .update(cx, |call, cx| call.hang_up(cx))
350                                .detach_and_log_err(cx);
351                        }),
352                )
353                .into_any_element(),
354        );
355
356        if can_use_microphone {
357            children.push(
358                IconButton::new(
359                    "mute-microphone",
360                    if is_muted {
361                        ui::IconName::MicMute
362                    } else {
363                        ui::IconName::Mic
364                    },
365                )
366                .tooltip(move |window, cx| {
367                    if is_muted {
368                        if is_deafened {
369                            Tooltip::with_meta(
370                                "Unmute Microphone",
371                                None,
372                                "Audio will be unmuted",
373                                window,
374                                cx,
375                            )
376                        } else {
377                            Tooltip::simple("Unmute Microphone", cx)
378                        }
379                    } else {
380                        Tooltip::simple("Mute Microphone", cx)
381                    }
382                })
383                .style(ButtonStyle::Subtle)
384                .icon_size(IconSize::Small)
385                .toggle_state(is_muted)
386                .selected_style(ButtonStyle::Tinted(TintColor::Error))
387                .on_click(move |_, _window, cx| {
388                    toggle_mute(&Default::default(), cx);
389                })
390                .into_any_element(),
391            );
392        }
393
394        children.push(
395            IconButton::new(
396                "mute-sound",
397                if is_deafened {
398                    ui::IconName::AudioOff
399                } else {
400                    ui::IconName::AudioOn
401                },
402            )
403            .style(ButtonStyle::Subtle)
404            .selected_style(ButtonStyle::Tinted(TintColor::Error))
405            .icon_size(IconSize::Small)
406            .toggle_state(is_deafened)
407            .tooltip(move |window, cx| {
408                if is_deafened {
409                    let label = "Unmute Audio";
410
411                    if !muted_by_user {
412                        Tooltip::with_meta(label, None, "Microphone will be unmuted", window, cx)
413                    } else {
414                        Tooltip::simple(label, cx)
415                    }
416                } else {
417                    let label = "Mute Audio";
418
419                    if !muted_by_user {
420                        Tooltip::with_meta(label, None, "Microphone will be muted", window, cx)
421                    } else {
422                        Tooltip::simple(label, cx)
423                    }
424                }
425            })
426            .on_click(move |_, _, cx| toggle_deafen(&Default::default(), cx))
427            .into_any_element(),
428        );
429
430        if can_use_microphone && screen_sharing_supported {
431            children.push(
432                IconButton::new("screen-share", ui::IconName::Screen)
433                    .style(ButtonStyle::Subtle)
434                    .icon_size(IconSize::Small)
435                    .toggle_state(is_screen_sharing)
436                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
437                    .tooltip(Tooltip::text(if is_screen_sharing {
438                        "Stop Sharing Screen"
439                    } else {
440                        "Share Screen"
441                    }))
442                    .on_click(move |_, window, cx| {
443                        toggle_screen_sharing(&Default::default(), window, cx)
444                    })
445                    .into_any_element(),
446            );
447        }
448
449        children.push(div().pr_2().into_any_element());
450
451        children
452    }
453}