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