collab.rs

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