Fix joining descendant channels, style channel invites

Max Brunsfeld and Mikayla created

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

assets/icons/channel_hash.svg                      |   0 
crates/client/src/channel_store.rs                 |   2 
crates/collab/src/db.rs                            |  34 +
crates/collab/src/tests/channel_tests.rs           |  32 +
crates/collab_ui/src/collab_panel.rs               | 334 ++++++++-------
crates/collab_ui/src/collab_panel/channel_modal.rs |   4 
crates/theme/src/theme.rs                          |   8 
styles/src/style_tree/collab_panel.ts              |  35 +
8 files changed, 258 insertions(+), 191 deletions(-)

Detailed changes

crates/client/src/channel_store.rs 🔗

@@ -104,7 +104,7 @@ impl ChannelStore {
         parent_id: Option<ChannelId>,
     ) -> impl Future<Output = Result<ChannelId>> {
         let client = self.client.clone();
-        let name = name.to_owned();
+        let name = name.trim_start_matches("#").to_owned();
         async move {
             Ok(client
                 .request(proto::CreateChannel { name, parent_id })

crates/collab/src/db.rs 🔗

@@ -1381,16 +1381,8 @@ impl Database {
     ) -> Result<RoomGuard<JoinRoom>> {
         self.room_transaction(room_id, |tx| async move {
             if let Some(channel_id) = channel_id {
-                channel_member::Entity::find()
-                    .filter(
-                        channel_member::Column::ChannelId
-                            .eq(channel_id)
-                            .and(channel_member::Column::UserId.eq(user_id))
-                            .and(channel_member::Column::Accepted.eq(true)),
-                    )
-                    .one(&*tx)
-                    .await?
-                    .ok_or_else(|| anyhow!("no such channel membership"))?;
+                self.check_user_is_channel_member(channel_id, user_id, &*tx)
+                    .await?;
 
                 room_participant::ActiveModel {
                     room_id: ActiveValue::set(room_id),
@@ -1738,7 +1730,6 @@ impl Database {
             }
 
             let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
-
             let channel_members = if let Some(channel_id) = channel_id {
                 self.get_channel_members_internal(channel_id, &tx).await?
             } else {
@@ -3595,6 +3586,25 @@ impl Database {
         Ok(user_ids)
     }
 
+    async fn check_user_is_channel_member(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+        tx: &DatabaseTransaction,
+    ) -> Result<()> {
+        let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
+        channel_member::Entity::find()
+            .filter(
+                channel_member::Column::ChannelId
+                    .is_in(channel_ids)
+                    .and(channel_member::Column::UserId.eq(user_id)),
+            )
+            .one(&*tx)
+            .await?
+            .ok_or_else(|| anyhow!("user is not a channel member"))?;
+        Ok(())
+    }
+
     async fn check_user_is_channel_admin(
         &self,
         channel_id: ChannelId,
@@ -3611,7 +3621,7 @@ impl Database {
             )
             .one(&*tx)
             .await?
-            .ok_or_else(|| anyhow!("user is not allowed to remove this channel"))?;
+            .ok_or_else(|| anyhow!("user is not a channel admin"))?;
         Ok(())
     }
 

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

@@ -313,6 +313,38 @@ fn assert_members_eq(
     );
 }
 
+#[gpui::test]
+async fn test_joining_channel_ancestor_member(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+
+    let parent_id = server
+        .make_channel("parent", (&client_a, cx_a), &mut [(&client_b, cx_b)])
+        .await;
+
+    let sub_id = client_a
+        .channel_store()
+        .update(cx_a, |channel_store, _| {
+            channel_store.create_channel("sub_channel", Some(parent_id))
+        })
+        .await
+        .unwrap();
+
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    assert!(active_call_b
+        .update(cx_b, |active_call, cx| active_call.join_channel(sub_id, cx))
+        .await
+        .is_ok());
+}
+
 #[gpui::test]
 async fn test_channel_room(
     deterministic: Arc<Deterministic>,

crates/collab_ui/src/collab_panel.rs 🔗

@@ -120,7 +120,8 @@ pub enum Event {
 enum Section {
     ActiveCall,
     Channels,
-    Requests,
+    ChannelInvites,
+    ContactRequests,
     Contacts,
     Online,
     Offline,
@@ -404,17 +405,55 @@ impl CollabPanel {
         let old_entries = mem::take(&mut self.entries);
 
         if let Some(room) = ActiveCall::global(cx).read(cx).room() {
-            let room = room.read(cx);
-            let mut participant_entries = Vec::new();
+            self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
 
-            // Populate the active user.
-            if let Some(user) = user_store.current_user() {
+            if !self.collapsed_sections.contains(&Section::ActiveCall) {
+                let room = room.read(cx);
+
+                // Populate the active user.
+                if let Some(user) = user_store.current_user() {
+                    self.match_candidates.clear();
+                    self.match_candidates.push(StringMatchCandidate {
+                        id: 0,
+                        string: user.github_login.clone(),
+                        char_bag: user.github_login.chars().collect(),
+                    });
+                    let matches = executor.block(match_strings(
+                        &self.match_candidates,
+                        &query,
+                        true,
+                        usize::MAX,
+                        &Default::default(),
+                        executor.clone(),
+                    ));
+                    if !matches.is_empty() {
+                        let user_id = user.id;
+                        self.entries.push(ListEntry::CallParticipant {
+                            user,
+                            is_pending: false,
+                        });
+                        let mut projects = room.local_participant().projects.iter().peekable();
+                        while let Some(project) = projects.next() {
+                            self.entries.push(ListEntry::ParticipantProject {
+                                project_id: project.id,
+                                worktree_root_names: project.worktree_root_names.clone(),
+                                host_user_id: user_id,
+                                is_last: projects.peek().is_none(),
+                            });
+                        }
+                    }
+                }
+
+                // Populate remote participants.
                 self.match_candidates.clear();
-                self.match_candidates.push(StringMatchCandidate {
-                    id: 0,
-                    string: user.github_login.clone(),
-                    char_bag: user.github_login.chars().collect(),
-                });
+                self.match_candidates
+                    .extend(room.remote_participants().iter().map(|(_, participant)| {
+                        StringMatchCandidate {
+                            id: participant.user.id as usize,
+                            string: participant.user.github_login.clone(),
+                            char_bag: participant.user.github_login.chars().collect(),
+                        }
+                    }));
                 let matches = executor.block(match_strings(
                     &self.match_candidates,
                     &query,
@@ -423,97 +462,54 @@ impl CollabPanel {
                     &Default::default(),
                     executor.clone(),
                 ));
-                if !matches.is_empty() {
-                    let user_id = user.id;
-                    participant_entries.push(ListEntry::CallParticipant {
-                        user,
+                for mat in matches {
+                    let user_id = mat.candidate_id as u64;
+                    let participant = &room.remote_participants()[&user_id];
+                    self.entries.push(ListEntry::CallParticipant {
+                        user: participant.user.clone(),
                         is_pending: false,
                     });
-                    let mut projects = room.local_participant().projects.iter().peekable();
+                    let mut projects = participant.projects.iter().peekable();
                     while let Some(project) = projects.next() {
-                        participant_entries.push(ListEntry::ParticipantProject {
+                        self.entries.push(ListEntry::ParticipantProject {
                             project_id: project.id,
                             worktree_root_names: project.worktree_root_names.clone(),
-                            host_user_id: user_id,
-                            is_last: projects.peek().is_none(),
+                            host_user_id: participant.user.id,
+                            is_last: projects.peek().is_none()
+                                && participant.video_tracks.is_empty(),
                         });
                     }
-                }
-            }
-
-            // Populate remote participants.
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(room.remote_participants().iter().map(|(_, participant)| {
-                    StringMatchCandidate {
-                        id: participant.user.id as usize,
-                        string: participant.user.github_login.clone(),
-                        char_bag: participant.user.github_login.chars().collect(),
+                    if !participant.video_tracks.is_empty() {
+                        self.entries.push(ListEntry::ParticipantScreen {
+                            peer_id: participant.peer_id,
+                            is_last: true,
+                        });
                     }
-                }));
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            for mat in matches {
-                let user_id = mat.candidate_id as u64;
-                let participant = &room.remote_participants()[&user_id];
-                participant_entries.push(ListEntry::CallParticipant {
-                    user: participant.user.clone(),
-                    is_pending: false,
-                });
-                let mut projects = participant.projects.iter().peekable();
-                while let Some(project) = projects.next() {
-                    participant_entries.push(ListEntry::ParticipantProject {
-                        project_id: project.id,
-                        worktree_root_names: project.worktree_root_names.clone(),
-                        host_user_id: participant.user.id,
-                        is_last: projects.peek().is_none() && participant.video_tracks.is_empty(),
-                    });
-                }
-                if !participant.video_tracks.is_empty() {
-                    participant_entries.push(ListEntry::ParticipantScreen {
-                        peer_id: participant.peer_id,
-                        is_last: true,
-                    });
                 }
-            }
 
-            // Populate pending participants.
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    room.pending_participants()
-                        .iter()
-                        .enumerate()
-                        .map(|(id, participant)| StringMatchCandidate {
+                // Populate pending participants.
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(room.pending_participants().iter().enumerate().map(
+                        |(id, participant)| StringMatchCandidate {
                             id,
                             string: participant.github_login.clone(),
                             char_bag: participant.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            participant_entries.extend(matches.iter().map(|mat| ListEntry::CallParticipant {
-                user: room.pending_participants()[mat.candidate_id].clone(),
-                is_pending: true,
-            }));
-
-            if !participant_entries.is_empty() {
-                self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
-                if !self.collapsed_sections.contains(&Section::ActiveCall) {
-                    self.entries.extend(participant_entries);
-                }
+                        },
+                    ));
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                self.entries
+                    .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
+                        user: room.pending_participants()[mat.candidate_id].clone(),
+                        is_pending: true,
+                    }));
             }
         }
 
@@ -559,8 +555,6 @@ impl CollabPanel {
             }
         }
 
-        self.entries.push(ListEntry::Header(Section::Contacts, 0));
-
         let mut request_entries = Vec::new();
         let channel_invites = channel_store.channel_invitations();
         if !channel_invites.is_empty() {
@@ -586,8 +580,19 @@ impl CollabPanel {
                     .iter()
                     .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())),
             );
+
+            if !request_entries.is_empty() {
+                self.entries
+                    .push(ListEntry::Header(Section::ChannelInvites, 1));
+                if !self.collapsed_sections.contains(&Section::ChannelInvites) {
+                    self.entries.append(&mut request_entries);
+                }
+            }
         }
 
+        self.entries.push(ListEntry::Header(Section::Contacts, 0));
+
+        request_entries.clear();
         let incoming = user_store.incoming_contact_requests();
         if !incoming.is_empty() {
             self.match_candidates.clear();
@@ -647,8 +652,9 @@ impl CollabPanel {
         }
 
         if !request_entries.is_empty() {
-            self.entries.push(ListEntry::Header(Section::Requests, 1));
-            if !self.collapsed_sections.contains(&Section::Requests) {
+            self.entries
+                .push(ListEntry::Header(Section::ContactRequests, 1));
+            if !self.collapsed_sections.contains(&Section::ContactRequests) {
                 self.entries.append(&mut request_entries);
             }
         }
@@ -1043,9 +1049,10 @@ impl CollabPanel {
         let tooltip_style = &theme.tooltip;
         let text = match section {
             Section::ActiveCall => "Current Call",
-            Section::Requests => "Requests",
+            Section::ContactRequests => "Requests",
             Section::Contacts => "Contacts",
             Section::Channels => "Channels",
+            Section::ChannelInvites => "Invites",
             Section::Online => "Online",
             Section::Offline => "Offline",
         };
@@ -1055,15 +1062,13 @@ impl CollabPanel {
             Section::ActiveCall => Some(
                 MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
                     render_icon_button(
-                        &theme.collab_panel.leave_call_button,
+                        theme.collab_panel.leave_call_button.in_state(is_selected),
                         "icons/radix/exit.svg",
                     )
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, |_, _, cx| {
-                    ActiveCall::global(cx)
-                        .update(cx, |call, cx| call.hang_up(cx))
-                        .detach_and_log_err(cx);
+                    Self::leave_call(cx);
                 })
                 .with_tooltip::<AddContact>(
                     0,
@@ -1076,7 +1081,7 @@ impl CollabPanel {
             Section::Contacts => Some(
                 MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |_, _| {
                     render_icon_button(
-                        &theme.collab_panel.add_contact_button,
+                        theme.collab_panel.add_contact_button.in_state(is_selected),
                         "icons/user_plus_16.svg",
                     )
                 })
@@ -1094,7 +1099,10 @@ impl CollabPanel {
             ),
             Section::Channels => Some(
                 MouseEventHandler::<AddChannel, Self>::new(0, cx, |_, _| {
-                    render_icon_button(&theme.collab_panel.add_contact_button, "icons/plus_16.svg")
+                    render_icon_button(
+                        theme.collab_panel.add_contact_button.in_state(is_selected),
+                        "icons/plus_16.svg",
+                    )
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
@@ -1284,10 +1292,10 @@ impl CollabPanel {
         MouseEventHandler::<Channel, Self>::new(channel.id as usize, cx, |state, cx| {
             Flex::row()
                 .with_child(
-                    Svg::new("icons/channels.svg")
-                        .with_color(theme.add_channel_button.color)
+                    Svg::new("icons/channel_hash.svg")
+                        .with_color(theme.channel_hash.color)
                         .constrained()
-                        .with_width(14.)
+                        .with_width(theme.channel_hash.width)
                         .aligned()
                         .left(),
                 )
@@ -1313,11 +1321,15 @@ impl CollabPanel {
                             }),
                     ),
                 )
+                .align_children_center()
                 .constrained()
                 .with_height(theme.row_height)
                 .contained()
                 .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
-                .with_margin_left(20. * channel.depth as f32)
+                .with_padding_left(
+                    theme.contact_row.default_style().padding.left
+                        + theme.channel_indent * channel.depth as f32,
+                )
         })
         .on_click(MouseButton::Left, move |_, this, cx| {
             this.join_channel(channel_id, cx);
@@ -1345,7 +1357,14 @@ impl CollabPanel {
         let button_spacing = theme.contact_button_spacing;
 
         Flex::row()
-            .with_child(Svg::new("icons/file_icons/hash.svg").aligned().left())
+            .with_child(
+                Svg::new("icons/channel_hash.svg")
+                    .with_color(theme.channel_hash.color)
+                    .constrained()
+                    .with_width(theme.channel_hash.width)
+                    .aligned()
+                    .left(),
+            )
             .with_child(
                 Label::new(channel.name.clone(), theme.contact_username.text.clone())
                     .contained()
@@ -1403,6 +1422,9 @@ impl CollabPanel {
                     .in_state(is_selected)
                     .style_for(&mut Default::default()),
             )
+            .with_padding_left(
+                theme.contact_row.default_style().padding.left + theme.channel_indent,
+            )
             .into_any()
     }
 
@@ -1532,30 +1554,23 @@ impl CollabPanel {
     }
 
     fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
-        let mut did_clear = self.filter_editor.update(cx, |editor, cx| {
-            if editor.buffer().read(cx).len(cx) > 0 {
-                editor.set_text("", cx);
-                true
-            } else {
-                false
-            }
-        });
-
-        did_clear |= self.take_editing_state(cx).is_some();
-
-        if !did_clear {
-            cx.emit(Event::Dismissed);
+        if self.take_editing_state(cx).is_some() {
+            cx.focus(&self.filter_editor);
+        } else {
+            self.filter_editor.update(cx, |editor, cx| {
+                if editor.buffer().read(cx).len(cx) > 0 {
+                    editor.set_text("", cx);
+                }
+            });
         }
+
+        self.update_entries(cx);
     }
 
     fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        let mut ix = self.selection.map_or(0, |ix| ix + 1);
-        while let Some(entry) = self.entries.get(ix) {
-            if entry.is_selectable() {
-                self.selection = Some(ix);
-                break;
-            }
-            ix += 1;
+        let ix = self.selection.map_or(0, |ix| ix + 1);
+        if ix < self.entries.len() {
+            self.selection = Some(ix);
         }
 
         self.list_state.reset(self.entries.len());
@@ -1569,16 +1584,9 @@ impl CollabPanel {
     }
 
     fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if let Some(mut ix) = self.selection.take() {
-            while ix > 0 {
-                ix -= 1;
-                if let Some(entry) = self.entries.get(ix) {
-                    if entry.is_selectable() {
-                        self.selection = Some(ix);
-                        break;
-                    }
-                }
-            }
+        let ix = self.selection.take().unwrap_or(0);
+        if ix > 0 {
+            self.selection = Some(ix - 1);
         }
 
         self.list_state.reset(self.entries.len());
@@ -1595,9 +1603,17 @@ impl CollabPanel {
         if let Some(selection) = self.selection {
             if let Some(entry) = self.entries.get(selection) {
                 match entry {
-                    ListEntry::Header(section, _) => {
-                        self.toggle_expanded(*section, cx);
-                    }
+                    ListEntry::Header(section, _) => match section {
+                        Section::ActiveCall => Self::leave_call(cx),
+                        Section::Channels => self.new_root_channel(cx),
+                        Section::Contacts => self.toggle_contact_finder(cx),
+                        Section::ContactRequests
+                        | Section::Online
+                        | Section::Offline
+                        | Section::ChannelInvites => {
+                            self.toggle_expanded(*section, cx);
+                        }
+                    },
                     ListEntry::Contact { contact, calling } => {
                         if contact.online && !contact.busy && !calling {
                             self.call(contact.user.id, Some(self.project.clone()), cx);
@@ -1626,6 +1642,9 @@ impl CollabPanel {
                             });
                         }
                     }
+                    ListEntry::Channel(channel) => {
+                        self.join_channel(channel.id, cx);
+                    }
                     _ => {}
                 }
             }
@@ -1651,6 +1670,12 @@ impl CollabPanel {
         self.update_entries(cx);
     }
 
+    fn leave_call(cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.hang_up(cx))
+            .detach_and_log_err(cx);
+    }
+
     fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
         if let Some(workspace) = self.workspace.upgrade(cx) {
             workspace.update(cx, |workspace, cx| {
@@ -1666,23 +1691,17 @@ impl CollabPanel {
     }
 
     fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
-        if self.channel_editing_state.is_none() {
-            self.channel_editing_state = Some(ChannelEditingState { parent_id: None });
-            self.update_entries(cx);
-        }
-
+        self.channel_editing_state = Some(ChannelEditingState { parent_id: None });
+        self.update_entries(cx);
         cx.focus(self.channel_name_editor.as_any());
         cx.notify();
     }
 
     fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
-        if self.channel_editing_state.is_none() {
-            self.channel_editing_state = Some(ChannelEditingState {
-                parent_id: Some(action.channel_id),
-            });
-            self.update_entries(cx);
-        }
-
+        self.channel_editing_state = Some(ChannelEditingState {
+            parent_id: Some(action.channel_id),
+        });
+        self.update_entries(cx);
         cx.focus(self.channel_name_editor.as_any());
         cx.notify();
     }
@@ -1825,6 +1844,13 @@ impl View for CollabPanel {
     fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
         if !self.has_focus {
             self.has_focus = true;
+            if !self.context_menu.is_focused(cx) {
+                if self.channel_editing_state.is_some() {
+                    cx.focus(&self.channel_name_editor);
+                } else {
+                    cx.focus(&self.filter_editor);
+                }
+            }
             cx.emit(Event::Focus);
         }
     }
@@ -1931,16 +1957,6 @@ impl Panel for CollabPanel {
     }
 }
 
-impl ListEntry {
-    fn is_selectable(&self) -> bool {
-        if let ListEntry::Header(_, 0) = self {
-            false
-        } else {
-            true
-        }
-    }
-}
-
 impl PartialEq for ListEntry {
     fn eq(&self, other: &Self) -> bool {
         match self {

crates/collab_ui/src/collab_panel/channel_modal.rs 🔗

@@ -487,7 +487,7 @@ impl ChannelModalDelegate {
         });
         cx.spawn(|picker, mut cx| async move {
             update.await?;
-            picker.update(&mut cx, |picker, cx| {
+            picker.update(&mut cx, |picker, _| {
                 let this = picker.delegate_mut();
                 if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) {
                     member.admin = admin;
@@ -503,7 +503,7 @@ impl ChannelModalDelegate {
         });
         cx.spawn(|picker, mut cx| async move {
             update.await?;
-            picker.update(&mut cx, |picker, cx| {
+            picker.update(&mut cx, |picker, _| {
                 let this = picker.delegate_mut();
                 if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
                     this.members.remove(ix);

crates/theme/src/theme.rs 🔗

@@ -220,12 +220,13 @@ pub struct CopilotAuthAuthorized {
 pub struct CollabPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
+    pub channel_hash: Icon,
     pub channel_modal: ChannelModal,
     pub user_query_editor: FieldEditor,
     pub user_query_editor_height: f32,
-    pub leave_call_button: IconButton,
-    pub add_contact_button: IconButton,
-    pub add_channel_button: IconButton,
+    pub leave_call_button: Toggleable<IconButton>,
+    pub add_contact_button: Toggleable<IconButton>,
+    pub add_channel_button: Toggleable<IconButton>,
     pub header_row: ContainedText,
     pub subheader_row: Toggleable<Interactive<ContainedText>>,
     pub leave_call: Interactive<ContainedText>,
@@ -239,6 +240,7 @@ pub struct CollabPanel {
     pub contact_username: ContainedText,
     pub contact_button: Interactive<IconButton>,
     pub contact_button_spacing: f32,
+    pub channel_indent: f32,
     pub disabled_button: IconButton,
     pub section_icon_size: f32,
     pub calling_indicator: ContainedText,

styles/src/style_tree/collab_panel.ts 🔗

@@ -51,6 +51,20 @@ export default function contacts_panel(): any {
         },
     }
 
+    const headerButton = toggleable({
+        base: {
+            color: foreground(layer, "on"),
+            button_width: 28,
+            icon_width: 16,
+        },
+        state: {
+            active: {
+                background: background(layer, "active"),
+                corner_radius: 8,
+            }
+        }
+    })
+
     return {
         channel_modal: channel_modal(),
         background: background(layer),
@@ -77,23 +91,16 @@ export default function contacts_panel(): any {
                 right: side_padding,
             },
         },
-        user_query_editor_height: 33,
-        add_contact_button: {
-            color: foreground(layer, "on"),
-            button_width: 28,
-            icon_width: 16,
-        },
-        add_channel_button: {
-            color: foreground(layer, "on"),
-            button_width: 28,
-            icon_width: 16,
-        },
-        leave_call_button: {
+        channel_hash: {
             color: foreground(layer, "on"),
-            button_width: 28,
-            icon_width: 16,
+            width: 14,
         },
+        user_query_editor_height: 33,
+        add_contact_button: headerButton,
+        add_channel_button: headerButton,
+        leave_call_button: headerButton,
         row_height: 28,
+        channel_indent: 10,
         section_icon_size: 8,
         header_row: {
             ...text(layer, "mono", { size: "sm", weight: "bold" }),