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