Detailed changes
@@ -156,7 +156,7 @@ impl Room {
cx.spawn(|this, mut cx| async move {
connect.await?;
this.update(&mut cx, |this, cx| {
- if !this.read_only() {
+ if this.can_use_microphone() {
if let Some(live_kit) = &this.live_kit {
if !live_kit.muted_by_user && !live_kit.deafened {
return this.share_microphone(cx);
@@ -1322,11 +1322,6 @@ impl Room {
})
}
- pub fn read_only(&self) -> bool {
- !(self.local_participant().role == proto::ChannelRole::Member
- || self.local_participant().role == proto::ChannelRole::Admin)
- }
-
pub fn is_speaking(&self) -> bool {
self.live_kit
.as_ref()
@@ -1337,6 +1332,22 @@ impl Room {
self.live_kit.as_ref().map(|live_kit| live_kit.deafened)
}
+ pub fn can_use_microphone(&self) -> bool {
+ use proto::ChannelRole::*;
+ match self.local_participant.role {
+ Admin | Member | Talker => true,
+ Guest | Banned => false,
+ }
+ }
+
+ pub fn can_share_projects(&self) -> bool {
+ use proto::ChannelRole::*;
+ match self.local_participant.role {
+ Admin | Member => true,
+ Guest | Banned | Talker => false,
+ }
+ }
+
#[track_caller]
pub fn share_microphone(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
if self.status.is_offline() {
@@ -120,7 +120,8 @@ impl ChannelMembership {
proto::ChannelRole::Admin => 0,
proto::ChannelRole::Member => 1,
proto::ChannelRole::Banned => 2,
- proto::ChannelRole::Guest => 3,
+ proto::ChannelRole::Talker => 3,
+ proto::ChannelRole::Guest => 4,
},
kind_order: match self.kind {
proto::channel_member::Kind::Member => 0,
@@ -100,8 +100,12 @@ pub enum ChannelRole {
#[sea_orm(string_value = "member")]
#[default]
Member,
+ /// Talker can read, but not write.
+ /// They can use microphones and the channel chat
+ #[sea_orm(string_value = "talker")]
+ Talker,
/// Guest can read, but not write.
- /// (thought they can use the channel chat)
+ /// They can not use microphones but can use the chat.
#[sea_orm(string_value = "guest")]
Guest,
/// Banned may not read.
@@ -114,8 +118,9 @@ impl ChannelRole {
pub fn should_override(&self, other: Self) -> bool {
use ChannelRole::*;
match self {
- Admin => matches!(other, Member | Banned | Guest),
- Member => matches!(other, Banned | Guest),
+ Admin => matches!(other, Member | Banned | Talker | Guest),
+ Member => matches!(other, Banned | Talker | Guest),
+ Talker => matches!(other, Guest),
Banned => matches!(other, Guest),
Guest => false,
}
@@ -134,7 +139,7 @@ impl ChannelRole {
use ChannelRole::*;
match self {
Admin | Member => true,
- Guest => visibility == ChannelVisibility::Public,
+ Guest | Talker => visibility == ChannelVisibility::Public,
Banned => false,
}
}
@@ -144,7 +149,7 @@ impl ChannelRole {
use ChannelRole::*;
match self {
Admin | Member => true,
- Guest | Banned => false,
+ Guest | Talker | Banned => false,
}
}
@@ -152,16 +157,16 @@ impl ChannelRole {
pub fn can_only_see_public_descendants(&self) -> bool {
use ChannelRole::*;
match self {
- Guest => true,
+ Guest | Talker => true,
Admin | Member | Banned => false,
}
}
/// True if the role can share screen/microphone/projects into rooms.
- pub fn can_publish_to_rooms(&self) -> bool {
+ pub fn can_use_microphone(&self) -> bool {
use ChannelRole::*;
match self {
- Admin | Member => true,
+ Admin | Member | Talker => true,
Guest | Banned => false,
}
}
@@ -171,7 +176,7 @@ impl ChannelRole {
use ChannelRole::*;
match self {
Admin | Member => true,
- Guest | Banned => false,
+ Talker | Guest | Banned => false,
}
}
@@ -179,7 +184,7 @@ impl ChannelRole {
pub fn can_read_projects(&self) -> bool {
use ChannelRole::*;
match self {
- Admin | Member | Guest => true,
+ Admin | Member | Guest | Talker => true,
Banned => false,
}
}
@@ -188,7 +193,7 @@ impl ChannelRole {
use ChannelRole::*;
match self {
Admin | Member => true,
- Banned | Guest => false,
+ Banned | Guest | Talker => false,
}
}
}
@@ -198,6 +203,7 @@ impl From<proto::ChannelRole> for ChannelRole {
match value {
proto::ChannelRole::Admin => ChannelRole::Admin,
proto::ChannelRole::Member => ChannelRole::Member,
+ proto::ChannelRole::Talker => ChannelRole::Talker,
proto::ChannelRole::Guest => ChannelRole::Guest,
proto::ChannelRole::Banned => ChannelRole::Banned,
}
@@ -209,6 +215,7 @@ impl Into<proto::ChannelRole> for ChannelRole {
match self {
ChannelRole::Admin => proto::ChannelRole::Admin,
ChannelRole::Member => proto::ChannelRole::Member,
+ ChannelRole::Talker => proto::ChannelRole::Talker,
ChannelRole::Guest => proto::ChannelRole::Guest,
ChannelRole::Banned => proto::ChannelRole::Banned,
}
@@ -795,6 +795,7 @@ impl Database {
match role {
Some(ChannelRole::Admin) => Ok(role.unwrap()),
Some(ChannelRole::Member)
+ | Some(ChannelRole::Talker)
| Some(ChannelRole::Banned)
| Some(ChannelRole::Guest)
| None => Err(anyhow!(
@@ -813,7 +814,10 @@ impl Database {
let channel_role = self.channel_role_for_user(channel, user_id, tx).await?;
match channel_role {
Some(ChannelRole::Admin) | Some(ChannelRole::Member) => Ok(channel_role.unwrap()),
- Some(ChannelRole::Banned) | Some(ChannelRole::Guest) | None => Err(anyhow!(
+ Some(ChannelRole::Banned)
+ | Some(ChannelRole::Guest)
+ | Some(ChannelRole::Talker)
+ | None => Err(anyhow!(
"user is not a channel member or channel does not exist"
))?,
}
@@ -828,9 +832,10 @@ impl Database {
) -> Result<ChannelRole> {
let role = self.channel_role_for_user(channel, user_id, tx).await?;
match role {
- Some(ChannelRole::Admin) | Some(ChannelRole::Member) | Some(ChannelRole::Guest) => {
- Ok(role.unwrap())
- }
+ Some(ChannelRole::Admin)
+ | Some(ChannelRole::Member)
+ | Some(ChannelRole::Guest)
+ | Some(ChannelRole::Talker) => Ok(role.unwrap()),
Some(ChannelRole::Banned) | None => Err(anyhow!(
"user is not a channel participant or channel does not exist"
))?,
@@ -51,7 +51,7 @@ impl Database {
if !participant
.role
.unwrap_or(ChannelRole::Member)
- .can_publish_to_rooms()
+ .can_edit_projects()
{
return Err(anyhow!("guests cannot share projects"))?;
}
@@ -169,7 +169,7 @@ impl Database {
let called_user_role = match caller.role.unwrap_or(ChannelRole::Member) {
ChannelRole::Admin | ChannelRole::Member => ChannelRole::Member,
- ChannelRole::Guest => ChannelRole::Guest,
+ ChannelRole::Guest | ChannelRole::Talker => ChannelRole::Guest,
ChannelRole::Banned => return Err(anyhow!("banned users cannot invite").into()),
};
@@ -28,7 +28,7 @@ use axum::{
Extension, Router, TypedHeader,
};
use collections::{HashMap, HashSet};
-pub use connection_pool::ConnectionPool;
+pub use connection_pool::{ConnectionPool, ZedVersion};
use futures::{
channel::oneshot,
future::{self, BoxFuture},
@@ -558,6 +558,7 @@ impl Server {
connection: Connection,
address: String,
user: User,
+ zed_version: ZedVersion,
impersonator: Option<User>,
mut send_connection_id: Option<oneshot::Sender<ConnectionId>>,
executor: Executor,
@@ -599,7 +600,7 @@ impl Server {
{
let mut pool = this.connection_pool.lock();
- pool.add_connection(connection_id, user_id, user.admin);
+ pool.add_connection(connection_id, user_id, user.admin, zed_version);
this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?;
this.peer.send(connection_id, build_update_user_channels(&channels_for_user))?;
this.peer.send(connection_id, build_channels_update(
@@ -879,17 +880,20 @@ pub async fn handle_websocket_request(
.into_response();
}
- // the first version of zed that sent this header was 0.121.x
- if let Some(version) = app_version_header.map(|header| header.0 .0) {
- // 0.123.0 was a nightly version with incompatible collab changes
- // that were reverted.
- if version == "0.123.0".parse().unwrap() {
- return (
- StatusCode::UPGRADE_REQUIRED,
- "client must be upgraded".to_string(),
- )
- .into_response();
- }
+ let Some(version) = app_version_header.map(|header| ZedVersion(header.0 .0)) else {
+ return (
+ StatusCode::UPGRADE_REQUIRED,
+ "no version header found".to_string(),
+ )
+ .into_response();
+ };
+
+ if !version.is_supported() {
+ return (
+ StatusCode::UPGRADE_REQUIRED,
+ "client must be upgraded".to_string(),
+ )
+ .into_response();
}
let socket_address = socket_address.to_string();
@@ -906,6 +910,7 @@ pub async fn handle_websocket_request(
connection,
socket_address,
user,
+ version,
impersonator.0,
None,
Executor::Production,
@@ -1311,6 +1316,22 @@ async fn set_room_participant_role(
response: Response<proto::SetRoomParticipantRole>,
session: Session,
) -> Result<()> {
+ let user_id = UserId::from_proto(request.user_id);
+ let role = ChannelRole::from(request.role());
+
+ if role == ChannelRole::Talker {
+ let pool = session.connection_pool().await;
+
+ for connection in pool.user_connections(user_id) {
+ if !connection.zed_version.supports_talker_role() {
+ Err(anyhow!(
+ "This user is on zed {} which does not support unmute",
+ connection.zed_version
+ ))?;
+ }
+ }
+ }
+
let (live_kit_room, can_publish) = {
let room = session
.db()
@@ -1318,13 +1339,13 @@ async fn set_room_participant_role(
.set_room_participant_role(
session.user_id,
RoomId::from_proto(request.room_id),
- UserId::from_proto(request.user_id),
- ChannelRole::from(request.role()),
+ user_id,
+ role,
)
.await?;
let live_kit_room = room.live_kit_room.clone();
- let can_publish = ChannelRole::from(request.role()).can_publish_to_rooms();
+ let can_publish = ChannelRole::from(request.role()).can_use_microphone();
room_updated(&room, &session.peer);
(live_kit_room, can_publish)
};
@@ -4,6 +4,7 @@ use collections::{BTreeMap, HashSet};
use rpc::ConnectionId;
use serde::Serialize;
use tracing::instrument;
+use util::SemanticVersion;
#[derive(Default, Serialize)]
pub struct ConnectionPool {
@@ -16,10 +17,30 @@ struct ConnectedUser {
connection_ids: HashSet<ConnectionId>,
}
+#[derive(Debug, Serialize)]
+pub struct ZedVersion(pub SemanticVersion);
+use std::fmt;
+
+impl fmt::Display for ZedVersion {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl ZedVersion {
+ pub fn is_supported(&self) -> bool {
+ self.0 != SemanticVersion::new(0, 123, 0)
+ }
+ pub fn supports_talker_role(&self) -> bool {
+ self.0 >= SemanticVersion::new(0, 125, 0)
+ }
+}
+
#[derive(Serialize)]
pub struct Connection {
pub user_id: UserId,
pub admin: bool,
+ pub zed_version: ZedVersion,
}
impl ConnectionPool {
@@ -29,9 +50,21 @@ impl ConnectionPool {
}
#[instrument(skip(self))]
- pub fn add_connection(&mut self, connection_id: ConnectionId, user_id: UserId, admin: bool) {
- self.connections
- .insert(connection_id, Connection { user_id, admin });
+ pub fn add_connection(
+ &mut self,
+ connection_id: ConnectionId,
+ user_id: UserId,
+ admin: bool,
+ zed_version: ZedVersion,
+ ) {
+ self.connections.insert(
+ connection_id,
+ Connection {
+ user_id,
+ admin,
+ zed_version,
+ },
+ );
let connected_user = self.connected_users.entry(user_id).or_default();
connected_user.connection_ids.insert(connection_id);
}
@@ -57,6 +90,19 @@ impl ConnectionPool {
self.connections.values()
}
+ pub fn user_connections(&self, user_id: UserId) -> impl Iterator<Item = &Connection> + '_ {
+ self.connected_users
+ .get(&user_id)
+ .into_iter()
+ .map(|state| {
+ state
+ .connection_ids
+ .iter()
+ .flat_map(|cid| self.connections.get(cid))
+ })
+ .flatten()
+ }
+
pub fn user_connection_ids(&self, user_id: UserId) -> impl Iterator<Item = ConnectionId> + '_ {
self.connected_users
.get(&user_id)
@@ -104,7 +104,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
});
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx)));
- assert!(room_b.read_with(cx_b, |room, _| room.read_only()));
+ assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
assert!(room_b
.update(cx_b, |room, cx| room.share_microphone(cx))
.await
@@ -130,7 +130,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx)));
// B sees themselves as muted, and can unmute.
- assert!(room_b.read_with(cx_b, |room, _| !room.read_only()));
+ assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
room_b.read_with(cx_b, |room, _| assert!(room.is_muted()));
room_b.update(cx_b, |room, cx| room.toggle_mute(cx));
cx_a.run_until_parked();
@@ -223,7 +223,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
let room_b = cx_b
.read(ActiveCall::global)
.update(cx_b, |call, _| call.room().unwrap().clone());
- assert!(room_b.read_with(cx_b, |room, _| room.read_only()));
+ assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
// A tries to grant write access to B, but cannot because B has not
// yet signed the zed CLA.
@@ -240,7 +240,26 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
.await
.unwrap_err();
cx_a.run_until_parked();
- assert!(room_b.read_with(cx_b, |room, _| room.read_only()));
+ assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects()));
+ assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
+
+ // A tries to grant write access to B, but cannot because B has not
+ // yet signed the zed CLA.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.room().unwrap().update(cx, |room, cx| {
+ room.set_participant_role(
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Talker,
+ cx,
+ )
+ })
+ })
+ .await
+ .unwrap();
+ cx_a.run_until_parked();
+ assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects()));
+ assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
// User B signs the zed CLA.
server
@@ -264,5 +283,6 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
.await
.unwrap();
cx_a.run_until_parked();
- assert!(room_b.read_with(cx_b, |room, _| !room.read_only()));
+ assert!(room_b.read_with(cx_b, |room, _| room.can_share_projects()));
+ assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
}
@@ -1,7 +1,7 @@
use crate::{
db::{tests::TestDb, NewUserParams, UserId},
executor::Executor,
- rpc::{Server, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
+ rpc::{Server, ZedVersion, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
AppState, Config,
};
use anyhow::anyhow;
@@ -37,7 +37,7 @@ use std::{
Arc,
},
};
-use util::http::FakeHttpClient;
+use util::{http::FakeHttpClient, SemanticVersion};
use workspace::{Workspace, WorkspaceStore};
pub struct TestServer {
@@ -231,6 +231,7 @@ impl TestServer {
server_conn,
client_name,
user,
+ ZedVersion(SemanticVersion::new(1, 0, 0)),
None,
Some(connection_id_tx),
Executor::Deterministic(cx.background_executor().clone()),
@@ -854,6 +854,10 @@ impl CollabPanel {
.into_any_element()
} else if role == proto::ChannelRole::Guest {
Label::new("Guest").color(Color::Muted).into_any_element()
+ } else if role == proto::ChannelRole::Talker {
+ Label::new("Mic only")
+ .color(Color::Muted)
+ .into_any_element()
} else {
div().into_any_element()
})
@@ -1013,13 +1017,38 @@ impl CollabPanel {
cx: &mut ViewContext<Self>,
) {
let this = cx.view().clone();
- if !(role == proto::ChannelRole::Guest || role == proto::ChannelRole::Member) {
+ if !(role == proto::ChannelRole::Guest
+ || role == proto::ChannelRole::Talker
+ || role == proto::ChannelRole::Member)
+ {
return;
}
- let context_menu = ContextMenu::build(cx, |context_menu, cx| {
+ let context_menu = ContextMenu::build(cx, |mut context_menu, cx| {
if role == proto::ChannelRole::Guest {
- context_menu.entry(
+ context_menu = context_menu.entry(
+ "Grant Mic Access",
+ None,
+ cx.handler_for(&this, move |_, cx| {
+ ActiveCall::global(cx)
+ .update(cx, |call, cx| {
+ let Some(room) = call.room() else {
+ return Task::ready(Ok(()));
+ };
+ room.update(cx, |room, cx| {
+ room.set_participant_role(
+ user_id,
+ proto::ChannelRole::Talker,
+ cx,
+ )
+ })
+ })
+ .detach_and_prompt_err("Failed to grant mic access", cx, |_, _| None)
+ }),
+ );
+ }
+ if role == proto::ChannelRole::Guest || role == proto::ChannelRole::Talker {
+ context_menu = context_menu.entry(
"Grant Write Access",
None,
cx.handler_for(&this, move |_, cx| {
@@ -1043,10 +1072,16 @@ impl CollabPanel {
}
})
}),
- )
- } else if role == proto::ChannelRole::Member {
- context_menu.entry(
- "Revoke Write Access",
+ );
+ }
+ if role == proto::ChannelRole::Member || role == proto::ChannelRole::Talker {
+ let label = if role == proto::ChannelRole::Talker {
+ "Mute"
+ } else {
+ "Revoke Access"
+ };
+ context_menu = context_menu.entry(
+ label,
None,
cx.handler_for(&this, move |_, cx| {
ActiveCall::global(cx)
@@ -1062,12 +1097,12 @@ impl CollabPanel {
)
})
})
- .detach_and_prompt_err("Failed to revoke write access", cx, |_, _| None)
+ .detach_and_prompt_err("Failed to revoke access", cx, |_, _| None)
}),
- )
- } else {
- unreachable!()
+ );
}
+
+ context_menu
});
cx.focus_view(&context_menu);
@@ -187,9 +187,10 @@ impl Render for CollabTitlebarItem {
let is_muted = room.is_muted();
let is_deafened = room.is_deafened().unwrap_or(false);
let is_screen_sharing = room.is_screen_sharing();
- let read_only = room.read_only();
+ let can_use_microphone = room.can_use_microphone();
+ let can_share_projects = room.can_share_projects();
- this.when(is_local && !read_only, |this| {
+ this.when(is_local && can_share_projects, |this| {
this.child(
Button::new(
"toggle_sharing",
@@ -235,7 +236,7 @@ impl Render for CollabTitlebarItem {
)
.pr_2(),
)
- .when(!read_only, |this| {
+ .when(can_use_microphone, |this| {
this.child(
IconButton::new(
"mute-microphone",
@@ -276,7 +277,7 @@ impl Render for CollabTitlebarItem {
.icon_size(IconSize::Small)
.selected(is_deafened)
.tooltip(move |cx| {
- if !read_only {
+ if can_use_microphone {
Tooltip::with_meta(
"Deafen Audio",
None,
@@ -289,7 +290,7 @@ impl Render for CollabTitlebarItem {
})
.on_click(move |_, cx| crate::toggle_deafen(&Default::default(), cx)),
)
- .when(!read_only, |this| {
+ .when(can_share_projects, |this| {
this.child(
IconButton::new("screen-share", ui::IconName::Screen)
.style(ButtonStyle::Subtle)
@@ -1107,6 +1107,7 @@ enum ChannelRole {
Member = 1;
Guest = 2;
Banned = 3;
+ Talker = 4;
}
message SetChannelMemberRole {
@@ -14,9 +14,18 @@ pub struct SemanticVersion {
pub patch: usize,
}
+impl SemanticVersion {
+ pub fn new(major: usize, minor: usize, patch: usize) -> Self {
+ Self {
+ major,
+ minor,
+ patch,
+ }
+ }
+}
+
impl FromStr for SemanticVersion {
type Err = anyhow::Error;
-
fn from_str(s: &str) -> Result<Self> {
let mut components = s.trim().split('.');
let major = components