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                                Label::new(format!("+{extra_count}"))
266                                    .ml_1()
267                                    .into_any_element(),
268                            )
269                        } else {
270                            None
271                        }),
272                ),
273        )
274    }
275
276    pub(crate) fn render_call_controls(&self, cx: &mut ViewContext<Self>) -> Vec<AnyElement> {
277        let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
278            return Vec::new();
279        };
280
281        let room = room.read(cx);
282        let project = self.project.read(cx);
283        let is_local = project.is_local();
284        let is_dev_server_project = project.dev_server_project_id().is_some();
285        let is_shared = (is_local || is_dev_server_project) && project.is_shared();
286        let is_muted = room.is_muted();
287        let is_deafened = room.is_deafened().unwrap_or(false);
288        let is_screen_sharing = room.is_screen_sharing();
289        let can_use_microphone = room.can_use_microphone();
290        let can_share_projects = room.can_share_projects();
291        let platform_supported = match self.platform_style {
292            PlatformStyle::Mac => true,
293            PlatformStyle::Linux | PlatformStyle::Windows => false,
294        };
295
296        let mut children = Vec::new();
297
298        if (is_local || is_dev_server_project) && can_share_projects {
299            children.push(
300                Button::new(
301                    "toggle_sharing",
302                    if is_shared { "Unshare" } else { "Share" },
303                )
304                .tooltip(move |cx| {
305                    Tooltip::text(
306                        if is_shared {
307                            "Stop sharing project with call participants"
308                        } else {
309                            "Share project with call participants"
310                        },
311                        cx,
312                    )
313                })
314                .style(ButtonStyle::Subtle)
315                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
316                .selected(is_shared)
317                .label_size(LabelSize::Small)
318                .on_click(cx.listener(move |this, _, cx| {
319                    if is_shared {
320                        this.unshare_project(&Default::default(), cx);
321                    } else {
322                        this.share_project(&Default::default(), 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(|cx| Tooltip::text("Leave call", cx))
336                        .icon_size(IconSize::Small)
337                        .on_click(move |_, 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 |cx| {
357                    Tooltip::text(
358                        if !platform_supported {
359                            "Cannot share microphone"
360                        } else if is_muted {
361                            "Unmute microphone"
362                        } else {
363                            "Mute microphone"
364                        },
365                        cx,
366                    )
367                })
368                .style(ButtonStyle::Subtle)
369                .icon_size(IconSize::Small)
370                .selected(platform_supported && is_muted)
371                .disabled(!platform_supported)
372                .selected_style(ButtonStyle::Tinted(TintColor::Negative))
373                .on_click(move |_, cx| {
374                    toggle_mute(&Default::default(), cx);
375                })
376                .into_any_element(),
377            );
378        }
379
380        children.push(
381            IconButton::new(
382                "mute-sound",
383                if is_deafened {
384                    ui::IconName::AudioOff
385                } else {
386                    ui::IconName::AudioOn
387                },
388            )
389            .style(ButtonStyle::Subtle)
390            .selected_style(ButtonStyle::Tinted(TintColor::Negative))
391            .icon_size(IconSize::Small)
392            .selected(is_deafened)
393            .disabled(!platform_supported)
394            .tooltip(move |cx| {
395                if !platform_supported {
396                    Tooltip::text("Cannot share microphone", cx)
397                } else if can_use_microphone {
398                    Tooltip::with_meta("Deafen Audio", None, "Mic will be muted", cx)
399                } else {
400                    Tooltip::text("Deafen Audio", cx)
401                }
402            })
403            .on_click(move |_, cx| toggle_deafen(&Default::default(), cx))
404            .into_any_element(),
405        );
406
407        if can_share_projects {
408            children.push(
409                IconButton::new("screen-share", ui::IconName::Screen)
410                    .style(ButtonStyle::Subtle)
411                    .icon_size(IconSize::Small)
412                    .selected(is_screen_sharing)
413                    .disabled(!platform_supported)
414                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
415                    .tooltip(move |cx| {
416                        Tooltip::text(
417                            if !platform_supported {
418                                "Cannot share screen"
419                            } else if is_screen_sharing {
420                                "Stop Sharing Screen"
421                            } else {
422                                "Share Screen"
423                            },
424                            cx,
425                        )
426                    })
427                    .on_click(move |_, cx| toggle_screen_sharing(&Default::default(), cx))
428                    .into_any_element(),
429            );
430        }
431
432        children.push(div().pr_2().into_any_element());
433
434        children
435    }
436}