single click channel (#7596)

Conrad Irwin and Nathan Sobo created

- Open channel notes and chat on channel click
- WIP
- Fix compile error
- Don't join live kit until requested
- Track in_call state separately from in_room



Release Notes:

- Improved channels: you can now be in a channel without joining the
audio call automatically

**or**

- N/A

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

assets/settings/default.json                                                        |   6 
crates/call/src/call_settings.rs                                                    |   6 
crates/call/src/participant.rs                                                      |   1 
crates/call/src/room.rs                                                             | 360 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql                      |   3 
crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql |   3 
crates/collab/src/db/queries/channels.rs                                            |  48 
crates/collab/src/db/queries/rooms.rs                                               |   6 
crates/collab/src/db/tables/room_participant.rs                                     |   1 
crates/collab/src/db/tests/channel_tests.rs                                         |  13 
crates/collab/src/rpc.rs                                                            | 159 
crates/collab/src/tests/channel_guest_tests.rs                                      |  13 
crates/collab/src/tests/channel_tests.rs                                            |   8 
crates/collab/src/tests/following_tests.rs                                          |   6 
crates/collab/src/tests/integration_tests.rs                                        |  44 
crates/collab/src/tests/test_server.rs                                              |  12 
crates/collab_ui/src/collab_panel.rs                                                | 261 
crates/collab_ui/src/collab_titlebar_item.rs                                        |  77 
crates/collab_ui/src/collab_ui.rs                                                   |  11 
crates/editor/src/display_map.rs                                                    |   2 
crates/live_kit_client/src/test.rs                                                  |  24 
crates/rpc/proto/zed.proto                                                          |  24 
crates/rpc/src/proto.rs                                                             |   7 
crates/workspace/src/pane.rs                                                        |   1 
crates/workspace/src/workspace.rs                                                   |  42 
crates/zed/src/main.rs                                                              |   4 
26 files changed, 656 insertions(+), 486 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -104,10 +104,8 @@
   "show_whitespaces": "selection",
   // Settings related to calls in Zed
   "calls": {
-    // Join calls with the microphone live by default
-    "mute_on_join": false,
-    // Share your project when you are the first to join a channel
-    "share_on_join": true
+    // Join calls with the microphone muted by default
+    "mute_on_join": false
   },
   // Toolbar related settings
   "toolbar": {

crates/call/src/call_settings.rs 🔗

@@ -7,7 +7,6 @@ use settings::Settings;
 #[derive(Deserialize, Debug)]
 pub struct CallSettings {
     pub mute_on_join: bool,
-    pub share_on_join: bool,
 }
 
 /// Configuration of voice calls in Zed.
@@ -17,11 +16,6 @@ pub struct CallSettingsContent {
     ///
     /// Default: false
     pub mute_on_join: Option<bool>,
-
-    /// Whether your current project should be shared when joining an empty channel.
-    ///
-    /// Default: true
-    pub share_on_join: Option<bool>,
 }
 
 impl Settings for CallSettings {

crates/call/src/participant.rs 🔗

@@ -49,6 +49,7 @@ pub struct RemoteParticipant {
     pub participant_index: ParticipantIndex,
     pub muted: bool,
     pub speaking: bool,
+    pub in_call: bool,
     pub video_tracks: HashMap<live_kit_client::Sid, Arc<RemoteVideoTrack>>,
     pub audio_tracks: HashMap<live_kit_client::Sid, Arc<RemoteAudioTrack>>,
 }

crates/call/src/room.rs 🔗

@@ -61,6 +61,7 @@ pub struct Room {
     id: u64,
     channel_id: Option<u64>,
     live_kit: Option<LiveKitRoom>,
+    live_kit_connection_info: Option<proto::LiveKitConnectionInfo>,
     status: RoomStatus,
     shared_projects: HashSet<WeakModel<Project>>,
     joined_projects: HashSet<WeakModel<Project>>,
@@ -112,91 +113,18 @@ impl Room {
         user_store: Model<UserStore>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
-        let live_kit_room = if let Some(connection_info) = live_kit_connection_info {
-            let room = live_kit_client::Room::new();
-            let mut status = room.status();
-            // Consume the initial status of the room.
-            let _ = status.try_recv();
-            let _maintain_room = cx.spawn(|this, mut cx| async move {
-                while let Some(status) = status.next().await {
-                    let this = if let Some(this) = this.upgrade() {
-                        this
-                    } else {
-                        break;
-                    };
-
-                    if status == live_kit_client::ConnectionState::Disconnected {
-                        this.update(&mut cx, |this, cx| this.leave(cx).log_err())
-                            .ok();
-                        break;
-                    }
-                }
-            });
-
-            let _handle_updates = cx.spawn({
-                let room = room.clone();
-                move |this, mut cx| async move {
-                    let mut updates = room.updates();
-                    while let Some(update) = updates.next().await {
-                        let this = if let Some(this) = this.upgrade() {
-                            this
-                        } else {
-                            break;
-                        };
-
-                        this.update(&mut cx, |this, cx| {
-                            this.live_kit_room_updated(update, cx).log_err()
-                        })
-                        .ok();
-                    }
-                }
-            });
-
-            let connect = room.connect(&connection_info.server_url, &connection_info.token);
-            cx.spawn(|this, mut cx| async move {
-                connect.await?;
-                this.update(&mut cx, |this, cx| {
-                    if !this.read_only() {
-                        if let Some(live_kit) = &this.live_kit {
-                            if !live_kit.muted_by_user && !live_kit.deafened {
-                                return this.share_microphone(cx);
-                            }
-                        }
-                    }
-                    Task::ready(Ok(()))
-                })?
-                .await
-            })
-            .detach_and_log_err(cx);
-
-            Some(LiveKitRoom {
-                room,
-                screen_track: LocalTrack::None,
-                microphone_track: LocalTrack::None,
-                next_publish_id: 0,
-                muted_by_user: Self::mute_on_join(cx),
-                deafened: false,
-                speaking: false,
-                _maintain_room,
-                _handle_updates,
-            })
-        } else {
-            None
-        };
-
         let maintain_connection = cx.spawn({
             let client = client.clone();
             move |this, cx| Self::maintain_connection(this, client.clone(), cx).log_err()
         });
 
-        Audio::play_sound(Sound::Joined, cx);
-
         let (room_update_completed_tx, room_update_completed_rx) = watch::channel();
 
-        Self {
+        let mut this = Self {
             id,
             channel_id,
-            live_kit: live_kit_room,
+            live_kit: None,
+            live_kit_connection_info,
             status: RoomStatus::Online,
             shared_projects: Default::default(),
             joined_projects: Default::default(),
@@ -220,7 +148,11 @@ impl Room {
             maintain_connection: Some(maintain_connection),
             room_update_completed_tx,
             room_update_completed_rx,
+        };
+        if this.live_kit_connection_info.is_some() {
+            this.join_call(cx).detach_and_log_err(cx);
         }
+        this
     }
 
     pub(crate) fn create(
@@ -279,7 +211,7 @@ impl Room {
         cx: AsyncAppContext,
     ) -> Result<Model<Self>> {
         Self::from_join_response(
-            client.request(proto::JoinChannel { channel_id }).await?,
+            client.request(proto::JoinChannel2 { channel_id }).await?,
             client,
             user_store,
             cx,
@@ -324,7 +256,7 @@ impl Room {
     }
 
     pub fn mute_on_join(cx: &AppContext) -> bool {
-        CallSettings::get_global(cx).mute_on_join || client::IMPERSONATE_LOGIN.is_some()
+        CallSettings::get_global(cx).mute_on_join
     }
 
     fn from_join_response(
@@ -374,7 +306,9 @@ impl Room {
         }
 
         log::info!("leaving room");
-        Audio::play_sound(Sound::Leave, cx);
+        if self.live_kit.is_some() {
+            Audio::play_sound(Sound::Leave, cx);
+        }
 
         self.clear_state(cx);
 
@@ -593,6 +527,24 @@ impl Room {
         &self.remote_participants
     }
 
+    pub fn call_participants(&self, cx: &AppContext) -> Vec<Arc<User>> {
+        self.remote_participants()
+            .values()
+            .filter_map(|participant| {
+                if participant.in_call {
+                    Some(participant.user.clone())
+                } else {
+                    None
+                }
+            })
+            .chain(if self.in_call() {
+                self.user_store.read(cx).current_user()
+            } else {
+                None
+            })
+            .collect()
+    }
+
     pub fn remote_participant_for_peer_id(&self, peer_id: PeerId) -> Option<&RemoteParticipant> {
         self.remote_participants
             .values()
@@ -617,10 +569,6 @@ impl Room {
         self.local_participant.role == proto::ChannelRole::Admin
     }
 
-    pub fn local_participant_is_guest(&self) -> bool {
-        self.local_participant.role == proto::ChannelRole::Guest
-    }
-
     pub fn set_participant_role(
         &mut self,
         user_id: u64,
@@ -828,6 +776,7 @@ impl Room {
                         }
 
                         let role = participant.role();
+                        let in_call = participant.in_call;
                         let location = ParticipantLocation::from_proto(participant.location)
                             .unwrap_or(ParticipantLocation::External);
                         if let Some(remote_participant) =
@@ -838,9 +787,15 @@ impl Room {
                             remote_participant.participant_index = participant_index;
                             if location != remote_participant.location
                                 || role != remote_participant.role
+                                || in_call != remote_participant.in_call
                             {
+                                if in_call && !remote_participant.in_call {
+                                    Audio::play_sound(Sound::Joined, cx);
+                                }
                                 remote_participant.location = location;
                                 remote_participant.role = role;
+                                remote_participant.in_call = participant.in_call;
+
                                 cx.emit(Event::ParticipantLocationChanged {
                                     participant_id: peer_id,
                                 });
@@ -857,12 +812,15 @@ impl Room {
                                     role,
                                     muted: true,
                                     speaking: false,
+                                    in_call: participant.in_call,
                                     video_tracks: Default::default(),
                                     audio_tracks: Default::default(),
                                 },
                             );
 
-                            Audio::play_sound(Sound::Joined, cx);
+                            if participant.in_call {
+                                Audio::play_sound(Sound::Joined, cx);
+                            }
 
                             if let Some(live_kit) = this.live_kit.as_ref() {
                                 let video_tracks =
@@ -1051,15 +1009,6 @@ impl Room {
             }
 
             RoomUpdate::SubscribedToRemoteAudioTrack(track, publication) => {
-                if let Some(live_kit) = &self.live_kit {
-                    if live_kit.deafened {
-                        track.stop();
-                        cx.foreground_executor()
-                            .spawn(publication.set_enabled(false))
-                            .detach();
-                    }
-                }
-
                 let user_id = track.publisher_id().parse()?;
                 let track_id = track.sid().to_string();
                 let participant = self
@@ -1206,7 +1155,7 @@ impl Room {
         })
     }
 
-    pub fn share_project(
+    pub(crate) fn share_project(
         &mut self,
         project: Model<Project>,
         cx: &mut ModelContext<Self>,
@@ -1308,18 +1257,14 @@ impl Room {
         })
     }
 
-    pub fn is_sharing_mic(&self) -> bool {
-        self.live_kit.as_ref().map_or(false, |live_kit| {
-            !matches!(live_kit.microphone_track, LocalTrack::None)
-        })
-    }
-
     pub fn is_muted(&self) -> bool {
-        self.live_kit.as_ref().map_or(false, |live_kit| {
-            matches!(live_kit.microphone_track, LocalTrack::None)
-                || live_kit.muted_by_user
-                || live_kit.deafened
-        })
+        self.live_kit
+            .as_ref()
+            .map_or(true, |live_kit| match &live_kit.microphone_track {
+                LocalTrack::None => true,
+                LocalTrack::Pending { .. } => true,
+                LocalTrack::Published { track_publication } => track_publication.is_muted(),
+            })
     }
 
     pub fn read_only(&self) -> bool {
@@ -1333,8 +1278,8 @@ impl Room {
             .map_or(false, |live_kit| live_kit.speaking)
     }
 
-    pub fn is_deafened(&self) -> Option<bool> {
-        self.live_kit.as_ref().map(|live_kit| live_kit.deafened)
+    pub fn in_call(&self) -> bool {
+        self.live_kit.is_some()
     }
 
     #[track_caller]
@@ -1387,12 +1332,8 @@ impl Room {
                         Ok(publication) => {
                             if canceled {
                                 live_kit.room.unpublish_track(publication);
+                                live_kit.microphone_track = LocalTrack::None;
                             } else {
-                                if live_kit.muted_by_user || live_kit.deafened {
-                                    cx.background_executor()
-                                        .spawn(publication.set_mute(true))
-                                        .detach();
-                                }
                                 live_kit.microphone_track = LocalTrack::Published {
                                     track_publication: publication,
                                 };
@@ -1496,50 +1437,140 @@ impl Room {
     }
 
     pub fn toggle_mute(&mut self, cx: &mut ModelContext<Self>) {
-        if let Some(live_kit) = self.live_kit.as_mut() {
-            // When unmuting, undeafen if the user was deafened before.
-            let was_deafened = live_kit.deafened;
-            if live_kit.muted_by_user
-                || live_kit.deafened
-                || matches!(live_kit.microphone_track, LocalTrack::None)
-            {
-                live_kit.muted_by_user = false;
-                live_kit.deafened = false;
-            } else {
-                live_kit.muted_by_user = true;
-            }
-            let muted = live_kit.muted_by_user;
-            let should_undeafen = was_deafened && !live_kit.deafened;
+        let muted = !self.is_muted();
+        if let Some(task) = self.set_mute(muted, cx) {
+            task.detach_and_log_err(cx);
+        }
+    }
 
-            if let Some(task) = self.set_mute(muted, cx) {
-                task.detach_and_log_err(cx);
-            }
+    pub fn join_call(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.live_kit.is_some() {
+            return Task::ready(Ok(()));
+        }
 
-            if should_undeafen {
-                if let Some(task) = self.set_deafened(false, cx) {
-                    task.detach_and_log_err(cx);
+        let room = live_kit_client::Room::new();
+        let mut status = room.status();
+        // Consume the initial status of the room.
+        let _ = status.try_recv();
+        let _maintain_room = cx.spawn(|this, mut cx| async move {
+            while let Some(status) = status.next().await {
+                let this = if let Some(this) = this.upgrade() {
+                    this
+                } else {
+                    break;
+                };
+
+                if status == live_kit_client::ConnectionState::Disconnected {
+                    this.update(&mut cx, |this, cx| this.leave(cx).log_err())
+                        .ok();
+                    break;
                 }
             }
-        }
-    }
+        });
 
-    pub fn toggle_deafen(&mut self, cx: &mut ModelContext<Self>) {
-        if let Some(live_kit) = self.live_kit.as_mut() {
-            // When deafening, mute the microphone if it was not already muted.
-            // When un-deafening, unmute the microphone, unless it was explicitly muted.
-            let deafened = !live_kit.deafened;
-            live_kit.deafened = deafened;
-            let should_change_mute = !live_kit.muted_by_user;
+        let _handle_updates = cx.spawn({
+            let room = room.clone();
+            move |this, mut cx| async move {
+                let mut updates = room.updates();
+                while let Some(update) = updates.next().await {
+                    let this = if let Some(this) = this.upgrade() {
+                        this
+                    } else {
+                        break;
+                    };
 
-            if let Some(task) = self.set_deafened(deafened, cx) {
-                task.detach_and_log_err(cx);
+                    this.update(&mut cx, |this, cx| {
+                        this.live_kit_room_updated(update, cx).log_err()
+                    })
+                    .ok();
+                }
             }
+        });
+
+        self.live_kit = Some(LiveKitRoom {
+            room: room.clone(),
+            screen_track: LocalTrack::None,
+            microphone_track: LocalTrack::None,
+            next_publish_id: 0,
+            speaking: false,
+            _maintain_room,
+            _handle_updates,
+        });
+
+        cx.spawn({
+            let client = self.client.clone();
+            let share_microphone = !self.read_only() && !Self::mute_on_join(cx);
+            let connection_info = self.live_kit_connection_info.clone();
+            let channel_id = self.channel_id;
+
+            move |this, mut cx| async move {
+                let connection_info = if let Some(connection_info) = connection_info {
+                    connection_info.clone()
+                } else if let Some(channel_id) = channel_id {
+                    if let Some(connection_info) = client
+                        .request(proto::JoinChannelCall { channel_id })
+                        .await?
+                        .live_kit_connection_info
+                    {
+                        connection_info
+                    } else {
+                        return Err(anyhow!("failed to get connection info from server"));
+                    }
+                } else {
+                    return Err(anyhow!(
+                        "tried to connect to livekit without connection info"
+                    ));
+                };
+                room.connect(&connection_info.server_url, &connection_info.token)
+                    .await?;
+
+                let track_updates = this.update(&mut cx, |this, cx| {
+                    Audio::play_sound(Sound::Joined, cx);
+                    let Some(live_kit) = this.live_kit.as_mut() else {
+                        return vec![];
+                    };
 
-            if should_change_mute {
-                if let Some(task) = self.set_mute(deafened, cx) {
-                    task.detach_and_log_err(cx);
+                    let mut track_updates = Vec::new();
+                    for participant in this.remote_participants.values() {
+                        for publication in live_kit
+                            .room
+                            .remote_audio_track_publications(&participant.user.id.to_string())
+                        {
+                            track_updates.push(publication.set_enabled(true));
+                        }
+
+                        for track in participant.audio_tracks.values() {
+                            track.start();
+                        }
+                    }
+                    track_updates
+                })?;
+
+                if share_microphone {
+                    this.update(&mut cx, |this, cx| this.share_microphone(cx))?
+                        .await?
+                };
+
+                for result in futures::future::join_all(track_updates).await {
+                    result?;
                 }
+                anyhow::Ok(())
             }
+        })
+    }
+
+    pub fn leave_call(&mut self, cx: &mut ModelContext<Self>) {
+        Audio::play_sound(Sound::Leave, cx);
+        if let Some(channel_id) = self.channel_id() {
+            let client = self.client.clone();
+            cx.background_executor()
+                .spawn(client.request(proto::LeaveChannelCall { channel_id }))
+                .detach_and_log_err(cx);
+            self.live_kit.take();
+            self.live_kit_connection_info.take();
+            cx.notify();
+        } else {
+            self.leave(cx).detach_and_log_err(cx)
         }
     }
 
@@ -1570,40 +1601,6 @@ impl Room {
         }
     }
 
-    fn set_deafened(
-        &mut self,
-        deafened: bool,
-        cx: &mut ModelContext<Self>,
-    ) -> Option<Task<Result<()>>> {
-        let live_kit = self.live_kit.as_mut()?;
-        cx.notify();
-
-        let mut track_updates = Vec::new();
-        for participant in self.remote_participants.values() {
-            for publication in live_kit
-                .room
-                .remote_audio_track_publications(&participant.user.id.to_string())
-            {
-                track_updates.push(publication.set_enabled(!deafened));
-            }
-
-            for track in participant.audio_tracks.values() {
-                if deafened {
-                    track.stop();
-                } else {
-                    track.start();
-                }
-            }
-        }
-
-        Some(cx.foreground_executor().spawn(async move {
-            for result in futures::future::join_all(track_updates).await {
-                result?;
-            }
-            Ok(())
-        }))
-    }
-
     fn set_mute(
         &mut self,
         should_mute: bool,
@@ -1648,9 +1645,6 @@ struct LiveKitRoom {
     room: Arc<live_kit_client::Room>,
     screen_track: LocalTrack,
     microphone_track: LocalTrack,
-    /// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user.
-    muted_by_user: bool,
-    deafened: bool,
     speaking: bool,
     next_publish_id: usize,
     _maintain_room: Task<()>,

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -163,7 +163,8 @@ CREATE TABLE "room_participants" (
     "calling_connection_id" INTEGER NOT NULL,
     "calling_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL,
     "participant_index" INTEGER,
-    "role" TEXT
+    "role" TEXT,
+    "in_call" BOOLEAN NOT NULL DEFAULT FALSE
 );
 CREATE UNIQUE INDEX "index_room_participants_on_user_id" ON "room_participants" ("user_id");
 CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id");

crates/collab/src/db/queries/channels.rs 🔗

@@ -97,11 +97,57 @@ impl Database {
         .await
     }
 
+    pub async fn set_in_channel_call(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+        in_call: bool,
+    ) -> Result<(proto::Room, ChannelRole)> {
+        self.transaction(move |tx| async move {
+            let channel = self.get_channel_internal(channel_id, &*tx).await?;
+            let role = self.channel_role_for_user(&channel, user_id, &*tx).await?;
+            if role.is_none() || role == Some(ChannelRole::Banned) {
+                Err(ErrorCode::Forbidden.anyhow())?
+            }
+            let role = role.unwrap();
+
+            let Some(room) = room::Entity::find()
+                .filter(room::Column::ChannelId.eq(channel_id))
+                .one(&*tx)
+                .await?
+            else {
+                Err(anyhow!("no room exists"))?
+            };
+
+            let result = room_participant::Entity::update_many()
+                .filter(
+                    Condition::all()
+                        .add(room_participant::Column::RoomId.eq(room.id))
+                        .add(room_participant::Column::UserId.eq(user_id)),
+                )
+                .set(room_participant::ActiveModel {
+                    in_call: ActiveValue::Set(in_call),
+                    ..Default::default()
+                })
+                .exec(&*tx)
+                .await?;
+
+            if result.rows_affected != 1 {
+                Err(anyhow!("not in channel"))?
+            }
+
+            let room = self.get_room(room.id, &*tx).await?;
+            Ok((room, role))
+        })
+        .await
+    }
+
     /// Adds a user to the specified channel.
     pub async fn join_channel(
         &self,
         channel_id: ChannelId,
         user_id: UserId,
+        autojoin: bool,
         connection: ConnectionId,
         environment: &str,
     ) -> Result<(JoinRoom, Option<MembershipUpdated>, ChannelRole)> {
@@ -166,7 +212,7 @@ impl Database {
                 .get_or_create_channel_room(channel_id, &live_kit_room, environment, &*tx)
                 .await?;
 
-            self.join_channel_room_internal(room_id, user_id, connection, role, &*tx)
+            self.join_channel_room_internal(room_id, user_id, autojoin, connection, role, &*tx)
                 .await
                 .map(|jr| (jr, accept_invite_result, role))
         })

crates/collab/src/db/queries/rooms.rs 🔗

@@ -135,6 +135,7 @@ impl Database {
                 ))),
                 participant_index: ActiveValue::set(Some(0)),
                 role: ActiveValue::set(Some(ChannelRole::Admin)),
+                in_call: ActiveValue::set(true),
 
                 id: ActiveValue::NotSet,
                 location_kind: ActiveValue::NotSet,
@@ -187,6 +188,7 @@ impl Database {
                 ))),
                 initial_project_id: ActiveValue::set(initial_project_id),
                 role: ActiveValue::set(Some(called_user_role)),
+                in_call: ActiveValue::set(true),
 
                 id: ActiveValue::NotSet,
                 answering_connection_id: ActiveValue::NotSet,
@@ -414,6 +416,7 @@ impl Database {
         &self,
         room_id: RoomId,
         user_id: UserId,
+        autojoin: bool,
         connection: ConnectionId,
         role: ChannelRole,
         tx: &DatabaseTransaction,
@@ -437,6 +440,8 @@ impl Database {
             ))),
             participant_index: ActiveValue::Set(Some(participant_index)),
             role: ActiveValue::set(Some(role)),
+            in_call: ActiveValue::set(autojoin),
+
             id: ActiveValue::NotSet,
             location_kind: ActiveValue::NotSet,
             location_project_id: ActiveValue::NotSet,
@@ -1258,6 +1263,7 @@ impl Database {
                         location: Some(proto::ParticipantLocation { variant: location }),
                         participant_index: participant_index as u32,
                         role: db_participant.role.unwrap_or(ChannelRole::Member).into(),
+                        in_call: db_participant.in_call,
                     },
                 );
             } else {

crates/collab/src/db/tests/channel_tests.rs 🔗

@@ -138,6 +138,7 @@ async fn test_joining_channels(db: &Arc<Database>) {
         .join_channel(
             channel_1,
             user_1,
+            false,
             ConnectionId { owner_id, id: 1 },
             TEST_RELEASE_CHANNEL,
         )
@@ -732,9 +733,15 @@ async fn test_guest_access(db: &Arc<Database>) {
         .await
         .is_err());
 
-    db.join_channel(zed_channel, guest, guest_connection, TEST_RELEASE_CHANNEL)
-        .await
-        .unwrap();
+    db.join_channel(
+        zed_channel,
+        guest,
+        false,
+        guest_connection,
+        TEST_RELEASE_CHANNEL,
+    )
+    .await
+    .unwrap();
 
     assert!(db
         .join_channel_chat(zed_channel, guest_connection, guest)

crates/collab/src/rpc.rs 🔗

@@ -105,6 +105,7 @@ struct Session {
     zed_environment: Arc<str>,
     user_id: UserId,
     connection_id: ConnectionId,
+    zed_version: SemanticVersion,
     db: Arc<tokio::sync::Mutex<DbHandle>>,
     peer: Arc<Peer>,
     connection_pool: Arc<parking_lot::Mutex<ConnectionPool>>,
@@ -131,6 +132,19 @@ impl Session {
             _not_send: PhantomData,
         }
     }
+
+    fn endpoint_removed_in(&self, endpoint: &str, version: SemanticVersion) -> anyhow::Result<()> {
+        if self.zed_version > version {
+            Err(anyhow!(
+                "{} was removed in {} (you're on {})",
+                endpoint,
+                version,
+                self.zed_version
+            ))
+        } else {
+            Ok(())
+        }
+    }
 }
 
 impl fmt::Debug for Session {
@@ -274,8 +288,11 @@ impl Server {
             .add_request_handler(get_channel_members)
             .add_request_handler(respond_to_channel_invite)
             .add_request_handler(join_channel)
+            .add_request_handler(join_channel2)
             .add_request_handler(join_channel_chat)
             .add_message_handler(leave_channel_chat)
+            .add_request_handler(join_channel_call)
+            .add_request_handler(leave_channel_call)
             .add_request_handler(send_channel_message)
             .add_request_handler(remove_channel_message)
             .add_request_handler(get_channel_messages)
@@ -559,6 +576,7 @@ impl Server {
         connection: Connection,
         address: String,
         user: User,
+        zed_version: SemanticVersion,
         impersonator: Option<User>,
         mut send_connection_id: Option<oneshot::Sender<ConnectionId>>,
         executor: Executor,
@@ -616,6 +634,7 @@ impl Server {
             let session = Session {
                 user_id,
                 connection_id,
+                zed_version,
                 db: Arc::new(tokio::sync::Mutex::new(DbHandle(this.app_state.db.clone()))),
                 zed_environment: this.app_state.config.zed_environment.clone(),
                 peer: this.peer.clone(),
@@ -866,7 +885,7 @@ pub fn routes(server: Arc<Server>) -> Router<Body> {
 
 pub async fn handle_websocket_request(
     TypedHeader(ProtocolVersion(protocol_version)): TypedHeader<ProtocolVersion>,
-    _app_version_header: Option<TypedHeader<AppVersionHeader>>,
+    app_version_header: Option<TypedHeader<AppVersionHeader>>,
     ConnectInfo(socket_address): ConnectInfo<SocketAddr>,
     Extension(server): Extension<Arc<Server>>,
     Extension(user): Extension<User>,
@@ -881,6 +900,12 @@ pub async fn handle_websocket_request(
             .into_response();
     }
 
+    // zed 0.122.x was the first version that sent an app header, so once that hits stable
+    // we can return UPGRADE_REQUIRED instead of unwrap_or_default();
+    let app_version = app_version_header
+        .map(|header| header.0 .0)
+        .unwrap_or_default();
+
     let socket_address = socket_address.to_string();
     ws.on_upgrade(move |socket| {
         use util::ResultExt;
@@ -895,6 +920,7 @@ pub async fn handle_websocket_request(
                     connection,
                     socket_address,
                     user,
+                    app_version,
                     impersonator.0,
                     None,
                     Executor::Production,
@@ -1037,7 +1063,7 @@ async fn join_room(
     let channel_id = session.db().await.channel_id_for_room(room_id).await?;
 
     if let Some(channel_id) = channel_id {
-        return join_channel_internal(channel_id, Box::new(response), session).await;
+        return join_channel_internal(channel_id, true, Box::new(response), session).await;
     }
 
     let joined_room = {
@@ -2700,14 +2726,67 @@ async fn respond_to_channel_invite(
     Ok(())
 }
 
-/// Join the channels' room
+/// Join the channels' call
 async fn join_channel(
     request: proto::JoinChannel,
     response: Response<proto::JoinChannel>,
     session: Session,
+) -> Result<()> {
+    session.endpoint_removed_in("join_channel", "0.123.0".parse().unwrap())?;
+
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    join_channel_internal(channel_id, true, Box::new(response), session).await
+}
+
+async fn join_channel2(
+    request: proto::JoinChannel2,
+    response: Response<proto::JoinChannel2>,
+    session: Session,
+) -> Result<()> {
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    join_channel_internal(channel_id, false, Box::new(response), session).await
+}
+
+async fn join_channel_call(
+    request: proto::JoinChannelCall,
+    response: Response<proto::JoinChannelCall>,
+    session: Session,
+) -> Result<()> {
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    let db = session.db().await;
+    let (joined_room, role) = db
+        .set_in_channel_call(channel_id, session.user_id, true)
+        .await?;
+
+    let Some(connection_info) = session.live_kit_client.as_ref().and_then(|live_kit| {
+        live_kit_info_for_user(live_kit, &session.user_id, role, &joined_room.live_kit_room)
+    }) else {
+        Err(anyhow!("no live kit token info"))?
+    };
+
+    room_updated(&joined_room, &session.peer);
+    response.send(proto::JoinChannelCallResponse {
+        live_kit_connection_info: Some(connection_info),
+    })?;
+
+    Ok(())
+}
+
+async fn leave_channel_call(
+    request: proto::LeaveChannelCall,
+    response: Response<proto::LeaveChannelCall>,
+    session: Session,
 ) -> Result<()> {
     let channel_id = ChannelId::from_proto(request.channel_id);
-    join_channel_internal(channel_id, Box::new(response), session).await
+    let db = session.db().await;
+    let (joined_room, _) = db
+        .set_in_channel_call(channel_id, session.user_id, false)
+        .await?;
+
+    room_updated(&joined_room, &session.peer);
+    response.send(proto::Ack {})?;
+
+    Ok(())
 }
 
 trait JoinChannelInternalResponse {
@@ -2723,9 +2802,15 @@ impl JoinChannelInternalResponse for Response<proto::JoinRoom> {
         Response::<proto::JoinRoom>::send(self, result)
     }
 }
+impl JoinChannelInternalResponse for Response<proto::JoinChannel2> {
+    fn send(self, result: proto::JoinRoomResponse) -> Result<()> {
+        Response::<proto::JoinChannel2>::send(self, result)
+    }
+}
 
 async fn join_channel_internal(
     channel_id: ChannelId,
+    autojoin: bool,
     response: Box<impl JoinChannelInternalResponse>,
     session: Session,
 ) -> Result<()> {
@@ -2737,39 +2822,22 @@ async fn join_channel_internal(
             .join_channel(
                 channel_id,
                 session.user_id,
+                autojoin,
                 session.connection_id,
                 session.zed_environment.as_ref(),
             )
             .await?;
 
         let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {
-            let (can_publish, token) = if role == ChannelRole::Guest {
-                (
-                    false,
-                    live_kit
-                        .guest_token(
-                            &joined_room.room.live_kit_room,
-                            &session.user_id.to_string(),
-                        )
-                        .trace_err()?,
-                )
-            } else {
-                (
-                    true,
-                    live_kit
-                        .room_token(
-                            &joined_room.room.live_kit_room,
-                            &session.user_id.to_string(),
-                        )
-                        .trace_err()?,
-                )
-            };
-
-            Some(LiveKitConnectionInfo {
-                server_url: live_kit.url().into(),
-                token,
-                can_publish,
-            })
+            if !autojoin {
+                return None;
+            }
+            live_kit_info_for_user(
+                live_kit,
+                &session.user_id,
+                role,
+                &joined_room.room.live_kit_room,
+            )
         });
 
         response.send(proto::JoinRoomResponse {
@@ -2805,6 +2873,35 @@ async fn join_channel_internal(
     Ok(())
 }
 
+fn live_kit_info_for_user(
+    live_kit: &Arc<dyn live_kit_server::api::Client>,
+    user_id: &UserId,
+    role: ChannelRole,
+    live_kit_room: &String,
+) -> Option<LiveKitConnectionInfo> {
+    let (can_publish, token) = if role == ChannelRole::Guest {
+        (
+            false,
+            live_kit
+                .guest_token(live_kit_room, &user_id.to_string())
+                .trace_err()?,
+        )
+    } else {
+        (
+            true,
+            live_kit
+                .room_token(live_kit_room, &user_id.to_string())
+                .trace_err()?,
+        )
+    };
+
+    Some(LiveKitConnectionInfo {
+        server_url: live_kit.url().into(),
+        token,
+        can_publish,
+    })
+}
+
 /// Start editing the channel notes
 async fn join_channel_buffer(
     request: proto::JoinChannelBuffer,

crates/collab/src/tests/channel_guest_tests.rs 🔗

@@ -1,4 +1,7 @@
-use crate::{db::ChannelId, tests::TestServer};
+use crate::{
+    db::ChannelId,
+    tests::{test_server::join_channel_call, TestServer},
+};
 use call::ActiveCall;
 use editor::Editor;
 use gpui::{BackgroundExecutor, TestAppContext};
@@ -32,7 +35,7 @@ async fn test_channel_guests(
     cx_a.executor().run_until_parked();
 
     // Client B joins channel A as a guest
-    cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
+    cx_b.update(|cx| workspace::open_channel(channel_id, client_b.app_state.clone(), None, cx))
         .await
         .unwrap();
 
@@ -72,7 +75,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
         .await;
 
     let project_a = client_a.build_test_project(cx_a).await;
-    cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx))
+    cx_a.update(|cx| workspace::open_channel(channel_id, client_a.app_state.clone(), None, cx))
         .await
         .unwrap();
 
@@ -84,11 +87,13 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
     cx_a.run_until_parked();
 
     // Client B joins channel A as a guest
-    cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
+    cx_b.update(|cx| workspace::open_channel(channel_id, client_b.app_state.clone(), None, cx))
         .await
         .unwrap();
     cx_a.run_until_parked();
 
+    join_channel_call(cx_b).await.unwrap();
+
     // client B opens 1.txt as a guest
     let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
     let room_b = cx_b

crates/collab/src/tests/channel_tests.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     db::{self, UserId},
     rpc::RECONNECT_TIMEOUT,
-    tests::{room_participants, RoomParticipants, TestServer},
+    tests::{room_participants, test_server::join_channel_call, RoomParticipants, TestServer},
 };
 use call::ActiveCall;
 use channel::{ChannelId, ChannelMembership, ChannelStore};
@@ -382,6 +382,7 @@ async fn test_channel_room(
         .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
         .await
         .unwrap();
+    join_channel_call(cx_a).await.unwrap();
 
     // Give everyone a chance to observe user A joining
     executor.run_until_parked();
@@ -429,7 +430,7 @@ async fn test_channel_room(
         .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
         .await
         .unwrap();
-
+    join_channel_call(cx_b).await.unwrap();
     executor.run_until_parked();
 
     cx_a.read(|cx| {
@@ -552,6 +553,9 @@ async fn test_channel_room(
         .await
         .unwrap();
 
+    join_channel_call(cx_a).await.unwrap();
+    join_channel_call(cx_b).await.unwrap();
+
     executor.run_until_parked();
 
     let room_a =

crates/collab/src/tests/following_tests.rs 🔗

@@ -24,7 +24,7 @@ use workspace::{
 
 use super::TestClient;
 
-#[gpui::test(iterations = 10)]
+#[gpui::test]
 async fn test_basic_following(
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
@@ -437,6 +437,7 @@ async fn test_basic_following(
         })
         .await
         .unwrap();
+
     executor.run_until_parked();
     let shared_screen = workspace_a.update(cx_a, |workspace, cx| {
         workspace
@@ -522,6 +523,7 @@ async fn test_basic_following(
         workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
         None
     );
+    executor.run_until_parked();
 }
 
 #[gpui::test]
@@ -2004,7 +2006,7 @@ async fn join_channel(
     client: &TestClient,
     cx: &mut TestAppContext,
 ) -> anyhow::Result<()> {
-    cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, cx))
+    cx.update(|cx| workspace::open_channel(channel_id, client.app_state.clone(), None, cx))
         .await
 }
 

crates/collab/src/tests/integration_tests.rs 🔗

@@ -1881,7 +1881,7 @@ fn active_call_events(cx: &mut TestAppContext) -> Rc<RefCell<Vec<room::Event>>>
 }
 
 #[gpui::test]
-async fn test_mute_deafen(
+async fn test_mute(
     executor: BackgroundExecutor,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
@@ -1920,7 +1920,7 @@ async fn test_mute_deafen(
     room_a.read_with(cx_a, |room, _| assert!(!room.is_muted()));
     room_b.read_with(cx_b, |room, _| assert!(!room.is_muted()));
 
-    // Users A and B are both muted.
+    // Users A and B are both unmuted.
     assert_eq!(
         participant_audio_state(&room_a, cx_a),
         &[ParticipantAudioState {
@@ -1962,30 +1962,6 @@ async fn test_mute_deafen(
         }]
     );
 
-    // User A deafens
-    room_a.update(cx_a, |room, cx| room.toggle_deafen(cx));
-    executor.run_until_parked();
-
-    // User A does not hear user B.
-    room_a.read_with(cx_a, |room, _| assert!(room.is_muted()));
-    room_b.read_with(cx_b, |room, _| assert!(!room.is_muted()));
-    assert_eq!(
-        participant_audio_state(&room_a, cx_a),
-        &[ParticipantAudioState {
-            user_id: client_b.user_id().unwrap(),
-            is_muted: false,
-            audio_tracks_playing: vec![false],
-        }]
-    );
-    assert_eq!(
-        participant_audio_state(&room_b, cx_b),
-        &[ParticipantAudioState {
-            user_id: client_a.user_id().unwrap(),
-            is_muted: true,
-            audio_tracks_playing: vec![true],
-        }]
-    );
-
     // User B calls user C, C joins.
     active_call_b
         .update(cx_b, |call, cx| {
@@ -2000,22 +1976,6 @@ async fn test_mute_deafen(
         .unwrap();
     executor.run_until_parked();
 
-    // User A does not hear users B or C.
-    assert_eq!(
-        participant_audio_state(&room_a, cx_a),
-        &[
-            ParticipantAudioState {
-                user_id: client_b.user_id().unwrap(),
-                is_muted: false,
-                audio_tracks_playing: vec![false],
-            },
-            ParticipantAudioState {
-                user_id: client_c.user_id().unwrap(),
-                is_muted: false,
-                audio_tracks_playing: vec![false],
-            }
-        ]
-    );
     assert_eq!(
         participant_audio_state(&room_b, cx_b),
         &[

crates/collab/src/tests/test_server.rs 🔗

@@ -13,7 +13,7 @@ use client::{
 use collections::{HashMap, HashSet};
 use fs::FakeFs;
 use futures::{channel::oneshot, StreamExt as _};
-use gpui::{BackgroundExecutor, Context, Model, TestAppContext, View, VisualTestContext};
+use gpui::{BackgroundExecutor, Context, Model, Task, TestAppContext, View, VisualTestContext};
 use language::LanguageRegistry;
 use node_runtime::FakeNodeRuntime;
 
@@ -36,7 +36,7 @@ use std::{
         Arc,
     },
 };
-use util::http::FakeHttpClient;
+use util::{http::FakeHttpClient, SemanticVersion};
 use workspace::{Workspace, WorkspaceStore};
 
 pub struct TestServer {
@@ -230,6 +230,7 @@ impl TestServer {
                                 server_conn,
                                 client_name,
                                 user,
+                                SemanticVersion::default(),
                                 None,
                                 Some(connection_id_tx),
                                 Executor::Deterministic(cx.background_executor().clone()),
@@ -685,7 +686,7 @@ impl TestClient {
         channel_id: u64,
         cx: &'a mut TestAppContext,
     ) -> (View<Workspace>, &'a mut VisualTestContext) {
-        cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, cx))
+        cx.update(|cx| workspace::open_channel(channel_id, self.app_state.clone(), None, cx))
             .await
             .unwrap();
         cx.run_until_parked();
@@ -760,6 +761,11 @@ impl TestClient {
     }
 }
 
+pub fn join_channel_call(cx: &mut TestAppContext) -> Task<anyhow::Result<()>> {
+    let room = cx.read(|cx| ActiveCall::global(cx).read(cx).room().cloned());
+    room.unwrap().update(cx, |room, cx| room.join_call(cx))
+}
+
 impl Drop for TestClient {
     fn drop(&mut self) {
         self.app_state.client.teardown();

crates/collab_ui/src/collab_panel.rs 🔗

@@ -40,7 +40,7 @@ use util::{maybe, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
-    OpenChannelNotes, Workspace,
+    Workspace,
 };
 
 actions!(
@@ -69,19 +69,6 @@ pub fn init(cx: &mut AppContext) {
         workspace.register_action(|workspace, _: &ToggleFocus, cx| {
             workspace.toggle_panel_focus::<CollabPanel>(cx);
         });
-        workspace.register_action(|_, _: &OpenChannelNotes, cx| {
-            let channel_id = ActiveCall::global(cx)
-                .read(cx)
-                .room()
-                .and_then(|room| room.read(cx).channel_id());
-
-            if let Some(channel_id) = channel_id {
-                let workspace = cx.view().clone();
-                cx.window_context().defer(move |cx| {
-                    ChannelView::open(channel_id, None, workspace, cx).detach_and_log_err(cx)
-                });
-            }
-        });
     })
     .detach();
 }
@@ -175,6 +162,9 @@ enum ListEntry {
         depth: usize,
         has_children: bool,
     },
+    ChannelCall {
+        channel_id: ChannelId,
+    },
     ChannelNotes {
         channel_id: ChannelId,
     },
@@ -382,6 +372,7 @@ impl CollabPanel {
 
                 if query.is_empty() {
                     if let Some(channel_id) = room.channel_id() {
+                        self.entries.push(ListEntry::ChannelCall { channel_id });
                         self.entries.push(ListEntry::ChannelNotes { channel_id });
                         self.entries.push(ListEntry::ChannelChat { channel_id });
                     }
@@ -479,7 +470,7 @@ impl CollabPanel {
                                 && participant.video_tracks.is_empty(),
                         });
                     }
-                    if !participant.video_tracks.is_empty() {
+                    if room.in_call() && !participant.video_tracks.is_empty() {
                         self.entries.push(ListEntry::ParticipantScreen {
                             peer_id: Some(participant.peer_id),
                             is_last: true,
@@ -832,8 +823,6 @@ impl CollabPanel {
         cx: &mut ViewContext<Self>,
     ) -> ListItem {
         let user_id = user.id;
-        let is_current_user =
-            self.user_store.read(cx).current_user().map(|user| user.id) == Some(user_id);
         let tooltip = format!("Follow {}", user.github_login);
 
         let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| {
@@ -846,12 +835,6 @@ impl CollabPanel {
             .selected(is_selected)
             .end_slot(if is_pending {
                 Label::new("Calling").color(Color::Muted).into_any_element()
-            } else if is_current_user {
-                IconButton::new("leave-call", IconName::Exit)
-                    .style(ButtonStyle::Subtle)
-                    .on_click(move |_, cx| Self::leave_call(cx))
-                    .tooltip(|cx| Tooltip::text("Leave Call", cx))
-                    .into_any_element()
             } else if role == proto::ChannelRole::Guest {
                 Label::new("Guest").color(Color::Muted).into_any_element()
             } else {
@@ -953,12 +936,88 @@ impl CollabPanel {
         }
     }
 
+    fn render_channel_call(
+        &self,
+        channel_id: ChannelId,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let (is_in_call, call_participants) = ActiveCall::global(cx)
+            .read(cx)
+            .room()
+            .map(|room| (room.read(cx).in_call(), room.read(cx).call_participants(cx)))
+            .unwrap_or_default();
+
+        const FACEPILE_LIMIT: usize = 3;
+
+        let face_pile = if !call_participants.is_empty() {
+            let extra_count = call_participants.len().saturating_sub(FACEPILE_LIMIT);
+            let result = FacePile::new(
+                call_participants
+                    .iter()
+                    .map(|user| Avatar::new(user.avatar_uri.clone()).into_any_element())
+                    .take(FACEPILE_LIMIT)
+                    .chain(if extra_count > 0 {
+                        Some(
+                            div()
+                                .ml_2()
+                                .child(Label::new(format!("+{extra_count}")))
+                                .into_any_element(),
+                        )
+                    } else {
+                        None
+                    })
+                    .collect::<SmallVec<_>>(),
+            );
+
+            Some(result)
+        } else {
+            None
+        };
+
+        ListItem::new("channel-call")
+            .selected(is_selected)
+            .start_slot(
+                h_flex()
+                    .gap_1()
+                    .child(render_tree_branch(false, true, cx))
+                    .child(IconButton::new(0, IconName::AudioOn)),
+            )
+            .when(is_in_call, |el| {
+                el.end_slot(
+                    IconButton::new(1, IconName::Exit)
+                        .style(ButtonStyle::Filled)
+                        .shape(ui::IconButtonShape::Square)
+                        .tooltip(|cx| Tooltip::text("Leave call", cx))
+                        .on_click(cx.listener(|this, _, cx| this.leave_channel_call(cx))),
+                )
+            })
+            .when(!is_in_call, |el| {
+                el.tooltip(move |cx| Tooltip::text("Join audio call", cx))
+                    .on_click(cx.listener(move |this, _, cx| {
+                        this.join_channel_call(channel_id, cx);
+                    }))
+            })
+            .child(
+                div()
+                    .text_ui()
+                    .when(!call_participants.is_empty(), |el| {
+                        el.font_weight(FontWeight::SEMIBOLD)
+                    })
+                    .child("call"),
+            )
+            .children(face_pile)
+    }
+
     fn render_channel_notes(
         &self,
         channel_id: ChannelId,
         is_selected: bool,
         cx: &mut ViewContext<Self>,
     ) -> impl IntoElement {
+        let channel_store = self.channel_store.read(cx);
+        let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
+
         ListItem::new("channel-notes")
             .selected(is_selected)
             .on_click(cx.listener(move |this, _, cx| {
@@ -970,7 +1029,14 @@ impl CollabPanel {
                     .child(render_tree_branch(false, true, cx))
                     .child(IconButton::new(0, IconName::File)),
             )
-            .child(Label::new("notes"))
+            .child(
+                div()
+                    .text_ui()
+                    .when(has_notes_notification, |el| {
+                        el.font_weight(FontWeight::SEMIBOLD)
+                    })
+                    .child("notes"),
+            )
             .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx))
     }
 
@@ -980,6 +1046,8 @@ impl CollabPanel {
         is_selected: bool,
         cx: &mut ViewContext<Self>,
     ) -> impl IntoElement {
+        let channel_store = self.channel_store.read(cx);
+        let has_messages_notification = channel_store.has_new_messages(channel_id);
         ListItem::new("channel-chat")
             .selected(is_selected)
             .on_click(cx.listener(move |this, _, cx| {
@@ -991,7 +1059,14 @@ impl CollabPanel {
                     .child(render_tree_branch(false, false, cx))
                     .child(IconButton::new(0, IconName::MessageBubbles)),
             )
-            .child(Label::new("chat"))
+            .child(
+                div()
+                    .text_ui()
+                    .when(has_messages_notification, |el| {
+                        el.font_weight(FontWeight::SEMIBOLD)
+                    })
+                    .child("chat"),
+            )
             .tooltip(move |cx| Tooltip::text("Open Chat", cx))
     }
 
@@ -1249,12 +1324,14 @@ impl CollabPanel {
         cx: &mut ViewContext<Self>,
     ) {
         let this = cx.view().clone();
-        let in_room = ActiveCall::global(cx).read(cx).room().is_some();
+        let room = ActiveCall::global(cx).read(cx).room();
+        let in_room = room.is_some();
+        let in_call = room.is_some_and(|room| room.read(cx).in_call());
 
         let context_menu = ContextMenu::build(cx, |mut context_menu, _| {
             let user_id = contact.user.id;
 
-            if contact.online && !contact.busy {
+            if contact.online && !contact.busy && (!in_room || in_call) {
                 let label = if in_room {
                     format!("Invite {} to join", contact.user.github_login)
                 } else {
@@ -1402,7 +1479,7 @@ impl CollabPanel {
                         if is_active {
                             self.open_channel_notes(channel.id, cx)
                         } else {
-                            self.join_channel(channel.id, cx)
+                            self.open_channel(channel.id, cx)
                         }
                     }
                     ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
@@ -1421,6 +1498,9 @@ impl CollabPanel {
                     ListEntry::ChannelInvite(channel) => {
                         self.respond_to_channel_invite(channel.id, true, cx)
                     }
+                    ListEntry::ChannelCall { channel_id } => {
+                        self.join_channel_call(*channel_id, cx)
+                    }
                     ListEntry::ChannelNotes { channel_id } => {
                         self.open_channel_notes(*channel_id, cx)
                     }
@@ -1883,14 +1963,14 @@ impl CollabPanel {
             .detach_and_prompt_err("Call failed", cx, |_, _| None);
     }
 
-    fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
+    fn open_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
         let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
             return;
         };
-        workspace::join_channel(
+        workspace::open_channel(
             channel_id,
             workspace.read(cx).app_state().clone(),
             Some(handle),
@@ -1899,6 +1979,23 @@ impl CollabPanel {
         .detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
     }
 
+    fn join_channel_call(&mut self, _channel_id: ChannelId, cx: &mut ViewContext<Self>) {
+        let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
+            return;
+        };
+
+        room.update(cx, |room, cx| room.join_call(cx))
+            .detach_and_prompt_err("Failed to join call", cx, |_, _| None)
+    }
+
+    fn leave_channel_call(&mut self, cx: &mut ViewContext<Self>) {
+        let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
+            return;
+        };
+
+        room.update(cx, |room, cx| room.leave_call(cx));
+    }
+
     fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
@@ -2024,6 +2121,9 @@ impl CollabPanel {
             ListEntry::ParticipantScreen { peer_id, is_last } => self
                 .render_participant_screen(*peer_id, *is_last, is_selected, cx)
                 .into_any_element(),
+            ListEntry::ChannelCall { channel_id } => self
+                .render_channel_call(*channel_id, is_selected, cx)
+                .into_any_element(),
             ListEntry::ChannelNotes { channel_id } => self
                 .render_channel_notes(*channel_id, is_selected, cx)
                 .into_any_element(),
@@ -2089,7 +2189,6 @@ impl CollabPanel {
         is_collapsed: bool,
         cx: &ViewContext<Self>,
     ) -> impl IntoElement {
-        let mut channel_link = None;
         let mut channel_tooltip_text = None;
         let mut channel_icon = None;
 
@@ -2100,13 +2199,12 @@ impl CollabPanel {
 
                     let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
 
-                    channel_link = Some(channel.link());
                     (channel_icon, channel_tooltip_text) = match channel.visibility {
                         proto::ChannelVisibility::Public => {
-                            (Some("icons/public.svg"), Some("Copy public channel link."))
+                            (Some(IconName::Public), Some("Close Channel"))
                         }
                         proto::ChannelVisibility::Members => {
-                            (Some("icons/hash.svg"), Some("Copy private channel link."))
+                            (Some(IconName::Hash), Some("Close Channel"))
                         }
                     };
 
@@ -2128,17 +2226,10 @@ impl CollabPanel {
         };
 
         let button = match section {
-            Section::ActiveCall => channel_link.map(|channel_link| {
-                let channel_link_copy = channel_link.clone();
-                IconButton::new("channel-link", IconName::Copy)
-                    .icon_size(IconSize::Small)
-                    .size(ButtonSize::None)
-                    .visible_on_hover("section-header")
-                    .on_click(move |_, cx| {
-                        let item = ClipboardItem::new(channel_link_copy.clone());
-                        cx.write_to_clipboard(item)
-                    })
-                    .tooltip(|cx| Tooltip::text("Copy channel link", cx))
+            Section::ActiveCall => channel_icon.map(|_| {
+                IconButton::new("channel-link", IconName::Close)
+                    .on_click(move |_, cx| Self::leave_call(cx))
+                    .tooltip(|cx| Tooltip::text("Close channel", cx))
                     .into_any_element()
             }),
             Section::Contacts => Some(
@@ -2173,6 +2264,9 @@ impl CollabPanel {
                             this.toggle_section_expanded(section, cx);
                         }))
                 })
+                .when_some(channel_icon, |el, channel_icon| {
+                    el.start_slot(Icon::new(channel_icon).color(Color::Muted))
+                })
                 .inset(true)
                 .end_slot::<AnyElement>(button)
                 .selected(is_selected),
@@ -2478,11 +2572,9 @@ impl CollabPanel {
                         }),
                     )
                     .on_click(cx.listener(move |this, _, cx| {
-                        if is_active {
-                            this.open_channel_notes(channel_id, cx)
-                        } else {
-                            this.join_channel(channel_id, cx)
-                        }
+                        this.open_channel(channel_id, cx);
+                        this.open_channel_notes(channel_id, cx);
+                        this.join_channel_chat(channel_id, cx);
                     }))
                     .on_secondary_mouse_down(cx.listener(
                         move |this, event: &MouseDownEvent, cx| {
@@ -2499,61 +2591,24 @@ impl CollabPanel {
                         .color(Color::Muted),
                     )
                     .child(
-                        h_flex()
-                            .id(channel_id as usize)
-                            .child(Label::new(channel.name.clone()))
-                            .children(face_pile.map(|face_pile| face_pile.p_1())),
+                        h_flex().id(channel_id as usize).child(
+                            div()
+                                .text_ui()
+                                .when(has_messages_notification || has_notes_notification, |el| {
+                                    el.font_weight(FontWeight::SEMIBOLD)
+                                })
+                                .child(channel.name.clone()),
+                        ),
                     ),
             )
-            .child(
+            .children(face_pile.map(|face_pile| {
                 h_flex()
                     .absolute()
                     .right(rems(0.))
                     .z_index(1)
                     .h_full()
-                    .child(
-                        h_flex()
-                            .h_full()
-                            .gap_1()
-                            .px_1()
-                            .child(
-                                IconButton::new("channel_chat", IconName::MessageBubbles)
-                                    .style(ButtonStyle::Filled)
-                                    .shape(ui::IconButtonShape::Square)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(if has_messages_notification {
-                                        Color::Default
-                                    } else {
-                                        Color::Muted
-                                    })
-                                    .on_click(cx.listener(move |this, _, cx| {
-                                        this.join_channel_chat(channel_id, cx)
-                                    }))
-                                    .tooltip(|cx| Tooltip::text("Open channel chat", cx))
-                                    .when(!has_messages_notification, |this| {
-                                        this.visible_on_hover("")
-                                    }),
-                            )
-                            .child(
-                                IconButton::new("channel_notes", IconName::File)
-                                    .style(ButtonStyle::Filled)
-                                    .shape(ui::IconButtonShape::Square)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(if has_notes_notification {
-                                        Color::Default
-                                    } else {
-                                        Color::Muted
-                                    })
-                                    .on_click(cx.listener(move |this, _, cx| {
-                                        this.open_channel_notes(channel_id, cx)
-                                    }))
-                                    .tooltip(|cx| Tooltip::text("Open channel notes", cx))
-                                    .when(!has_notes_notification, |this| {
-                                        this.visible_on_hover("")
-                                    }),
-                            ),
-                    ),
-            )
+                    .child(face_pile.p_1())
+            }))
             .tooltip({
                 let channel_store = self.channel_store.clone();
                 move |cx| {
@@ -2757,6 +2812,14 @@ impl PartialEq for ListEntry {
                     return channel_1.id == channel_2.id;
                 }
             }
+            ListEntry::ChannelCall { channel_id } => {
+                if let ListEntry::ChannelCall {
+                    channel_id: other_id,
+                } = other
+                {
+                    return channel_id == other_id;
+                }
+            }
             ListEntry::ChannelNotes { channel_id } => {
                 if let ListEntry::ChannelNotes {
                     channel_id: other_id,
@@ -2855,7 +2918,7 @@ impl Render for JoinChannelTooltip {
                 .read(cx)
                 .channel_participants(self.channel_id);
 
-            div.child(Label::new("Join Channel"))
+            div.child(Label::new("Open Channel"))
                 .children(participants.iter().map(|participant| {
                     h_flex()
                         .gap_2()

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -102,6 +102,10 @@ impl Render for CollabTitlebarItem {
                                 room.remote_participants().values().collect::<Vec<_>>();
                             remote_participants.sort_by_key(|p| p.participant_index.0);
 
+                            if !room.in_call() {
+                                return this;
+                            }
+
                             let current_user_face_pile = self.render_collaborator(
                                 &current_user,
                                 peer_id,
@@ -133,6 +137,10 @@ impl Render for CollabTitlebarItem {
                                             == ParticipantLocation::SharedProject { project_id }
                                     });
 
+                                    if !collaborator.in_call {
+                                        return None;
+                                    }
+
                                     let face_pile = self.render_collaborator(
                                         &collaborator.user,
                                         collaborator.peer_id,
@@ -185,7 +193,7 @@ impl Render for CollabTitlebarItem {
                         let is_local = project.is_local();
                         let is_shared = is_local && project.is_shared();
                         let is_muted = room.is_muted();
-                        let is_deafened = room.is_deafened().unwrap_or(false);
+                        let is_connected_to_livekit = room.in_call();
                         let is_screen_sharing = room.is_screen_sharing();
                         let read_only = room.read_only();
 
@@ -220,22 +228,28 @@ impl Render for CollabTitlebarItem {
                                 )),
                             )
                         })
-                        .child(
-                            div()
-                                .child(
-                                    IconButton::new("leave-call", ui::IconName::Exit)
-                                        .style(ButtonStyle::Subtle)
-                                        .tooltip(|cx| Tooltip::text("Leave call", cx))
-                                        .icon_size(IconSize::Small)
-                                        .on_click(move |_, cx| {
-                                            ActiveCall::global(cx)
-                                                .update(cx, |call, cx| call.hang_up(cx))
-                                                .detach_and_log_err(cx);
-                                        }),
-                                )
-                                .pr_2(),
-                        )
-                        .when(!read_only, |this| {
+                        .when(is_connected_to_livekit, |el| {
+                            el.child(
+                                div()
+                                    .child(
+                                        IconButton::new("leave-call", ui::IconName::Exit)
+                                            .style(ButtonStyle::Subtle)
+                                            .tooltip(|cx| Tooltip::text("Leave call", cx))
+                                            .icon_size(IconSize::Small)
+                                            .on_click(move |_, cx| {
+                                                ActiveCall::global(cx).update(cx, |call, cx| {
+                                                    if let Some(room) = call.room() {
+                                                        room.update(cx, |room, cx| {
+                                                            room.leave_call(cx)
+                                                        })
+                                                    }
+                                                })
+                                            }),
+                                    )
+                                    .pl_2(),
+                            )
+                        })
+                        .when(!read_only && is_connected_to_livekit, |this| {
                             this.child(
                                 IconButton::new(
                                     "mute-microphone",
@@ -262,34 +276,7 @@ impl Render for CollabTitlebarItem {
                                 .on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
                             )
                         })
-                        .child(
-                            IconButton::new(
-                                "mute-sound",
-                                if is_deafened {
-                                    ui::IconName::AudioOff
-                                } else {
-                                    ui::IconName::AudioOn
-                                },
-                            )
-                            .style(ButtonStyle::Subtle)
-                            .selected_style(ButtonStyle::Tinted(TintColor::Negative))
-                            .icon_size(IconSize::Small)
-                            .selected(is_deafened)
-                            .tooltip(move |cx| {
-                                if !read_only {
-                                    Tooltip::with_meta(
-                                        "Deafen Audio",
-                                        None,
-                                        "Mic will be muted",
-                                        cx,
-                                    )
-                                } else {
-                                    Tooltip::text("Deafen Audio", cx)
-                                }
-                            })
-                            .on_click(move |_, cx| crate::toggle_deafen(&Default::default(), cx)),
-                        )
-                        .when(!read_only, |this| {
+                        .when(!read_only && is_connected_to_livekit, |this| {
                             this.child(
                                 IconButton::new("screen-share", ui::IconName::Screen)
                                     .style(ButtonStyle::Subtle)

crates/collab_ui/src/collab_ui.rs 🔗

@@ -22,10 +22,7 @@ pub use panel_settings::{
 use settings::Settings;
 use workspace::{notifications::DetachAndPromptErr, AppState};
 
-actions!(
-    collab,
-    [ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall]
-);
+actions!(collab, [ToggleScreenSharing, ToggleMute, LeaveCall]);
 
 pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
     CollaborationPanelSettings::register(cx);
@@ -85,12 +82,6 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) {
     }
 }
 
-pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
-    if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
-        room.update(cx, |room, cx| room.toggle_deafen(cx));
-    }
-}
-
 fn notification_window_options(
     screen: Rc<dyn PlatformDisplay>,
     window_size: Size<Pixels>,

crates/editor/src/display_map.rs 🔗

@@ -3,7 +3,7 @@
 //! Not literally though - rendering, layout and all that jazz is a responsibility of [`EditorElement`][EditorElement].
 //! Instead, [`DisplayMap`] decides where Inlays/Inlay hints are displayed, when
 //! to apply a soft wrap, where to add fold indicators, whether there are any tabs in the buffer that
-//! we display as spaces and where to display custom blocks (like diagnostics).
+//! we display as spaces and where to display custom blocks (like diagnostics)
 //! Seems like a lot? That's because it is. [`DisplayMap`] is conceptually made up
 //! of several smaller structures that form a hierarchy (starting at the bottom):
 //! - [`InlayMap`] that decides where the [`Inlay`]s should be displayed.

crates/live_kit_client/src/test.rs 🔗

@@ -54,7 +54,7 @@ impl TestServer {
         Ok(SERVERS
             .lock()
             .get(url)
-            .ok_or_else(|| anyhow!("no server found for url"))?
+            .ok_or_else(|| anyhow!("no server found for url: {}", url))?
             .clone())
     }
 
@@ -160,7 +160,6 @@ impl TestServer {
 
     async fn remove_participant(&self, room_name: String, identity: String) -> Result<()> {
         // TODO: clear state associated with the `Room`.
-
         self.executor.simulate_random_delay().await;
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
@@ -414,6 +413,15 @@ struct TestServerRoom {
     participant_permissions: HashMap<Sid, proto::ParticipantPermission>,
 }
 
+impl Drop for TestServerRoom {
+    fn drop(&mut self) {
+        for room in self.client_rooms.values() {
+            let mut state = room.0.lock();
+            *state.connection.0.borrow_mut() = ConnectionState::Disconnected;
+        }
+    }
+}
+
 #[derive(Debug)]
 struct TestServerVideoTrack {
     sid: Sid,
@@ -694,11 +702,15 @@ impl LocalTrackPublication {
 
     pub fn is_muted(&self) -> bool {
         if let Some(room) = self.room.upgrade() {
-            room.test_server()
-                .is_track_muted(&room.token(), &self.sid)
-                .unwrap_or(false)
+            if room.is_connected() {
+                room.test_server()
+                    .is_track_muted(&room.token(), &self.sid)
+                    .unwrap_or(true)
+            } else {
+                true
+            }
         } else {
-            false
+            true
         }
     }
 

crates/rpc/proto/zed.proto 🔗

@@ -183,7 +183,12 @@ message Envelope {
         LspExtExpandMacroResponse lsp_ext_expand_macro_response = 155;
         SetRoomParticipantRole set_room_participant_role = 156;
 
-        UpdateUserChannels update_user_channels = 157; // current max
+        UpdateUserChannels update_user_channels = 157;
+
+        JoinChannel2 join_channel2 = 158;
+        JoinChannelCall join_channel_call = 159;
+        JoinChannelCallResponse join_channel_call_response = 160;
+        LeaveChannelCall leave_channel_call = 161; // current max
     }
 }
 
@@ -291,6 +296,7 @@ message Participant {
     ParticipantLocation location = 4;
     uint32 participant_index = 5;
     ChannelRole role = 6;
+    bool in_call = 7;
 }
 
 message PendingParticipant {
@@ -1033,6 +1039,22 @@ message JoinChannel {
     uint64 channel_id = 1;
 }
 
+message JoinChannel2 {
+    uint64 channel_id = 1;
+}
+
+message JoinChannelCall {
+    uint64 channel_id = 1;
+}
+
+message JoinChannelCallResponse {
+    LiveKitConnectionInfo live_kit_connection_info = 1;
+}
+
+message LeaveChannelCall {
+    uint64 channel_id = 1;
+}
+
 message DeleteChannel {
     uint64 channel_id = 1;
 }

crates/rpc/src/proto.rs 🔗

@@ -198,6 +198,7 @@ messages!(
     (InlayHints, Background),
     (InlayHintsResponse, Background),
     (InviteChannelMember, Foreground),
+    (JoinChannel2, Foreground),
     (JoinChannel, Foreground),
     (JoinChannelBuffer, Foreground),
     (JoinChannelBufferResponse, Foreground),
@@ -208,6 +209,9 @@ messages!(
     (JoinRoom, Foreground),
     (JoinRoomResponse, Foreground),
     (LeaveChannelBuffer, Background),
+    (JoinChannelCall, Foreground),
+    (JoinChannelCallResponse, Foreground),
+    (LeaveChannelCall, Foreground),
     (LeaveChannelChat, Foreground),
     (LeaveProject, Foreground),
     (LeaveRoom, Foreground),
@@ -324,6 +328,9 @@ request_messages!(
     (InlayHints, InlayHintsResponse),
     (InviteChannelMember, Ack),
     (JoinChannel, JoinRoomResponse),
+    (JoinChannel2, JoinRoomResponse),
+    (JoinChannelCall, JoinChannelCallResponse),
+    (LeaveChannelCall, Ack),
     (JoinChannelBuffer, JoinChannelBufferResponse),
     (JoinChannelChat, JoinChannelChatResponse),
     (JoinProject, JoinProjectResponse),

crates/workspace/src/pane.rs 🔗

@@ -762,6 +762,7 @@ impl Pane {
         save_intent: SaveIntent,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
+        println!("{}", std::backtrace::Backtrace::force_capture());
         self.close_items(cx, save_intent, move |view_id| view_id == item_id_to_close)
     }
 

crates/workspace/src/workspace.rs 🔗

@@ -12,7 +12,7 @@ mod toolbar;
 mod workspace_settings;
 
 use anyhow::{anyhow, Context as _, Result};
-use call::{call_settings::CallSettings, ActiveCall};
+use call::ActiveCall;
 use client::{
     proto::{self, ErrorCode, PeerId},
     Client, ErrorExt, Status, TypedEnvelope, UserStore,
@@ -3977,8 +3977,6 @@ pub async fn last_opened_workspace_paths() -> Option<WorkspaceLocation> {
     DB.last_workspace().await.log_err().flatten()
 }
 
-actions!(collab, [OpenChannelNotes]);
-
 async fn join_channel_internal(
     channel_id: u64,
     app_state: &Arc<AppState>,
@@ -4080,36 +4078,6 @@ async fn join_channel_internal(
             return Some(join_remote_project(project, host, app_state.clone(), cx));
         }
 
-        // if you are the first to join a channel, share your project
-        if room.remote_participants().len() == 0 && !room.local_participant_is_guest() {
-            if let Some(workspace) = requesting_window {
-                let project = workspace.update(cx, |workspace, cx| {
-                    if !CallSettings::get_global(cx).share_on_join {
-                        return None;
-                    }
-                    let project = workspace.project.read(cx);
-                    if project.is_local()
-                        && project.visible_worktrees(cx).any(|tree| {
-                            tree.read(cx)
-                                .root_entry()
-                                .map_or(false, |entry| entry.is_dir())
-                        })
-                    {
-                        Some(workspace.project.clone())
-                    } else {
-                        None
-                    }
-                });
-                if let Ok(Some(project)) = project {
-                    return Some(cx.spawn(|room, mut cx| async move {
-                        room.update(&mut cx, |room, cx| room.share_project(project, cx))?
-                            .await?;
-                        Ok(())
-                    }));
-                }
-            }
-        }
-
         None
     })?;
     if let Some(task) = task {
@@ -4119,7 +4087,7 @@ async fn join_channel_internal(
     anyhow::Ok(false)
 }
 
-pub fn join_channel(
+pub fn open_channel(
     channel_id: u64,
     app_state: Arc<AppState>,
     requesting_window: Option<WindowHandle<Workspace>>,
@@ -4152,12 +4120,6 @@ pub fn join_channel(
                 })?
                 .await?;
 
-            if result.is_ok() {
-                cx.update(|cx| {
-                    cx.dispatch_action(&OpenChannelNotes);
-                }).log_err();
-            }
-
             active_window = Some(window_handle);
         }
 

crates/zed/src/main.rs 🔗

@@ -314,7 +314,7 @@ fn main() {
                 cx.spawn(|cx| async move {
                     // ignore errors here, we'll show a generic "not signed in"
                     let _ = authenticate(client, &cx).await;
-                    cx.update(|cx| workspace::join_channel(channel_id, app_state, None, cx))?
+                    cx.update(|cx| workspace::open_channel(channel_id, app_state, None, cx))?
                         .await?;
                     anyhow::Ok(())
                 })
@@ -369,7 +369,7 @@ fn main() {
                         cx.update(|mut cx| {
                             cx.spawn(|cx| async move {
                                 cx.update(|cx| {
-                                    workspace::join_channel(channel_id, app_state, None, cx)
+                                    workspace::open_channel(channel_id, app_state, None, cx)
                                 })?
                                 .await?;
                                 anyhow::Ok(())