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 = cx.is_screen_capture_supported();
303
304 let mut children = Vec::new();
305
306 if is_local && can_share_projects && !is_connecting_to_project {
307 children.push(
308 Button::new(
309 "toggle_sharing",
310 if is_shared { "Unshare" } else { "Share" },
311 )
312 .tooltip(Tooltip::text(if is_shared {
313 "Stop sharing project with call participants"
314 } else {
315 "Share project with call participants"
316 }))
317 .style(ButtonStyle::Subtle)
318 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
319 .toggle_state(is_shared)
320 .label_size(LabelSize::Small)
321 .on_click(cx.listener(move |this, _, window, cx| {
322 if is_shared {
323 this.unshare_project(window, cx);
324 } else {
325 this.share_project(cx);
326 }
327 }))
328 .into_any_element(),
329 );
330 }
331
332 children.push(
333 div()
334 .pr_2()
335 .child(
336 IconButton::new("leave-call", ui::IconName::Exit)
337 .style(ButtonStyle::Subtle)
338 .tooltip(Tooltip::text("Leave call"))
339 .icon_size(IconSize::Small)
340 .on_click(move |_, _window, cx| {
341 ActiveCall::global(cx)
342 .update(cx, |call, cx| call.hang_up(cx))
343 .detach_and_log_err(cx);
344 }),
345 )
346 .into_any_element(),
347 );
348
349 if can_use_microphone {
350 children.push(
351 IconButton::new(
352 "mute-microphone",
353 if is_muted {
354 ui::IconName::MicMute
355 } else {
356 ui::IconName::Mic
357 },
358 )
359 .tooltip(move |window, cx| {
360 if is_muted {
361 if is_deafened {
362 Tooltip::with_meta(
363 "Unmute Microphone",
364 None,
365 "Audio will be unmuted",
366 window,
367 cx,
368 )
369 } else {
370 Tooltip::simple("Unmute Microphone", cx)
371 }
372 } else {
373 Tooltip::simple("Mute Microphone", cx)
374 }
375 })
376 .style(ButtonStyle::Subtle)
377 .icon_size(IconSize::Small)
378 .toggle_state(is_muted)
379 .selected_style(ButtonStyle::Tinted(TintColor::Error))
380 .on_click(move |_, _window, cx| {
381 toggle_mute(&Default::default(), cx);
382 })
383 .into_any_element(),
384 );
385 }
386
387 children.push(
388 IconButton::new(
389 "mute-sound",
390 if is_deafened {
391 ui::IconName::AudioOff
392 } else {
393 ui::IconName::AudioOn
394 },
395 )
396 .style(ButtonStyle::Subtle)
397 .selected_style(ButtonStyle::Tinted(TintColor::Error))
398 .icon_size(IconSize::Small)
399 .toggle_state(is_deafened)
400 .tooltip(move |window, cx| {
401 if is_deafened {
402 let label = "Unmute Audio";
403
404 if !muted_by_user {
405 Tooltip::with_meta(label, None, "Microphone will be unmuted", window, cx)
406 } else {
407 Tooltip::simple(label, cx)
408 }
409 } else {
410 let label = "Mute Audio";
411
412 if !muted_by_user {
413 Tooltip::with_meta(label, None, "Microphone will be muted", window, cx)
414 } else {
415 Tooltip::simple(label, cx)
416 }
417 }
418 })
419 .on_click(move |_, _, cx| toggle_deafen(&Default::default(), cx))
420 .into_any_element(),
421 );
422
423 if can_use_microphone && screen_sharing_supported {
424 children.push(
425 IconButton::new("screen-share", ui::IconName::Screen)
426 .style(ButtonStyle::Subtle)
427 .icon_size(IconSize::Small)
428 .toggle_state(is_screen_sharing)
429 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
430 .tooltip(Tooltip::text(if is_screen_sharing {
431 "Stop Sharing Screen"
432 } else {
433 "Share Screen"
434 }))
435 .on_click(move |_, window, cx| {
436 toggle_screen_sharing(&Default::default(), window, cx)
437 })
438 .into_any_element(),
439 );
440 }
441
442 children.push(div().pr_2().into_any_element());
443
444 children
445 }
446}