talkers (#8158)

Conrad Irwin created

Release Notes:

- Added an "Unmute" action for guests in calls. This lets them use the
mic, but not edit projects.

Change summary

crates/call/src/room.rs                        | 23 +++++--
crates/channel/src/channel_store.rs            |  3 
crates/collab/src/db/ids.rs                    | 29 ++++++---
crates/collab/src/db/queries/channels.rs       | 13 +++-
crates/collab/src/db/queries/projects.rs       |  2 
crates/collab/src/db/queries/rooms.rs          |  2 
crates/collab/src/rpc.rs                       | 53 ++++++++++++-----
crates/collab/src/rpc/connection_pool.rs       | 52 +++++++++++++++++-
crates/collab/src/tests/channel_guest_tests.rs | 30 ++++++++-
crates/collab/src/tests/test_server.rs         |  5 +
crates/collab_ui/src/collab_panel.rs           | 57 ++++++++++++++++---
crates/collab_ui/src/collab_titlebar_item.rs   | 11 ++-
crates/rpc/proto/zed.proto                     |  1 
crates/util/src/semantic_version.rs            | 11 +++
14 files changed, 225 insertions(+), 67 deletions(-)

Detailed changes

crates/call/src/room.rs 🔗

@@ -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() {

crates/channel/src/channel_store.rs 🔗

@@ -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,

crates/collab/src/db/ids.rs 🔗

@@ -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,
         }

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

@@ -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"
             ))?,

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

@@ -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"))?;
             }

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

@@ -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()),
             };
 

crates/collab/src/rpc.rs 🔗

@@ -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)
     };

crates/collab/src/rpc/connection_pool.rs 🔗

@@ -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)

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

@@ -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()));
 }

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

@@ -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()),

crates/collab_ui/src/collab_panel.rs 🔗

@@ -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);

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -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)

crates/rpc/proto/zed.proto 🔗

@@ -1107,6 +1107,7 @@ enum ChannelRole {
     Member = 1;
     Guest = 2;
     Banned = 3;
+    Talker = 4;
 }
 
 message SetChannelMemberRole {

crates/util/src/semantic_version.rs 🔗

@@ -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