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 ¤t_user,
129 peer_id,
130 true,
131 room.is_speaking(),
132 room.is_muted(),
133 None,
134 room,
135 project_id,
136 ¤t_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 ¤t_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}