
Release Notes: - Added channel reordering for administrators (use `cmd-up` and `cmd-down` on macOS or `ctrl-up` `ctrl-down` on Linux to move channels up or down within their parent) ## Summary This PR introduces the ability for channel administrators to reorder channels within their parent context, providing better organizational control over channel hierarchies. Users can now move channels up or down relative to their siblings using keyboard shortcuts. ## Problem Previously, channels were displayed in alphabetical order with no way to customize their arrangement. This made it difficult for teams to organize channels in a logical order that reflected their workflow or importance, forcing users to prefix channel names with numbers or special characters as a workaround. ## Solution The implementation adds a persistent `channel_order` field to channels that determines their display order within their parent. Channels with the same parent are sorted by this field rather than alphabetically. ## Implementation Details ### Database Schema Added a new column and index to support efficient ordering: ```sql -- crates/collab/migrations/20250530175450_add_channel_order.sql ALTER TABLE channels ADD COLUMN channel_order INTEGER NOT NULL DEFAULT 1; CREATE INDEX CONCURRENTLY "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order"); ``` ### RPC Protocol Extended the channel proto with ordering support: ```proto // crates/proto/proto/channel.proto message Channel { uint64 id = 1; string name = 2; ChannelVisibility visibility = 3; int32 channel_order = 4; repeated uint64 parent_path = 5; } message ReorderChannel { uint64 channel_id = 1; enum Direction { Up = 0; Down = 1; } Direction direction = 2; } ``` ### Server-side Logic The reordering is handled by swapping `channel_order` values between adjacent channels: ```rust // crates/collab/src/db/queries/channels.rs pub async fn reorder_channel( &self, channel_id: ChannelId, direction: proto::reorder_channel::Direction, user_id: UserId, ) -> Result<Vec<Channel>> { // Find the sibling channel to swap with let sibling_channel = match direction { proto::reorder_channel::Direction::Up => { // Find channel with highest order less than current channel::Entity::find() .filter( channel::Column::ParentPath .eq(&channel.parent_path) .and(channel::Column::ChannelOrder.lt(channel.channel_order)), ) .order_by_desc(channel::Column::ChannelOrder) .one(&*tx) .await? } // Similar logic for Down... }; // Swap the channel_order values let temp_order = channel.channel_order; channel.channel_order = sibling_channel.channel_order; sibling_channel.channel_order = temp_order; } ``` ### Client-side Sorting Optimized the sorting algorithm to avoid O(n²) complexity: ```rust // crates/collab/src/db/queries/channels.rs // Pre-compute sort keys for efficient O(n log n) sorting let mut channels_with_keys: Vec<(Vec<i32>, Channel)> = channels .into_iter() .map(|channel| { let mut sort_key = Vec::with_capacity(channel.parent_path.len() + 1); // Build sort key from parent path orders for parent_id in &channel.parent_path { sort_key.push(channel_order_map.get(parent_id).copied().unwrap_or(i32::MAX)); } sort_key.push(channel.channel_order); (sort_key, channel) }) .collect(); channels_with_keys.sort_by(|a, b| a.0.cmp(&b.0)); ``` ### User Interface Added keyboard shortcuts and proper context handling: ```json // assets/keymaps/default-macos.json { "context": "CollabPanel && not_editing", "bindings": { "cmd-up": "collab_panel::MoveChannelUp", "cmd-down": "collab_panel::MoveChannelDown" } } ``` The CollabPanel now properly sets context to distinguish between editing and navigation modes: ```rust // crates/collab_ui/src/collab_panel.rs fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("CollabPanel"); dispatch_context.add("menu"); let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window) { "editing" } else { "not_editing" }; dispatch_context.add(identifier); dispatch_context } ``` ## Testing Comprehensive tests were added to verify: - Basic reordering functionality (up/down movement) - Boundary conditions (first/last channels) - Permission checks (non-admins cannot reorder) - Ordering persistence across server restarts - Correct broadcasting of changes to channel members ## Migration Strategy Existing channels are assigned initial `channel_order` values based on their current alphabetical sorting to maintain the familiar order users expect: ```sql UPDATE channels SET channel_order = ( SELECT ROW_NUMBER() OVER ( PARTITION BY parent_path ORDER BY name, id ) FROM channels c2 WHERE c2.id = channels.id ); ``` ## Future Enhancements While this PR provides basic reordering functionality, potential future improvements could include: - Drag-and-drop reordering in the UI - Bulk reordering operations - Custom sorting strategies (by activity, creation date, etc.) ## Checklist - [x] Database migration included - [x] Tests added for new functionality - [x] Keybindings work on macOS and Linux - [x] Permissions properly enforced - [x] Error handling implemented throughout - [x] Manual testing completed - [x] Documentation updated --------- Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
1017 lines
30 KiB
Rust
1017 lines
30 KiB
Rust
use crate::{
|
|
db::{
|
|
Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId, UserId,
|
|
tests::{assert_channel_tree_matches, channel_tree, new_test_connection, new_test_user},
|
|
},
|
|
test_both_dbs,
|
|
};
|
|
use rpc::{
|
|
ConnectionId,
|
|
proto::{self, reorder_channel},
|
|
};
|
|
use std::{collections::HashSet, sync::Arc};
|
|
|
|
test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite);
|
|
|
|
async fn test_channels(db: &Arc<Database>) {
|
|
let a_id = new_test_user(db, "user1@example.com").await;
|
|
let b_id = new_test_user(db, "user2@example.com").await;
|
|
|
|
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
|
|
|
|
// Make sure that people cannot read channels they haven't been invited to
|
|
assert!(db.get_channel(zed_id, b_id).await.is_err());
|
|
|
|
db.invite_channel_member(zed_id, b_id, a_id, ChannelRole::Member)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.respond_to_channel_invite(zed_id, b_id, true)
|
|
.await
|
|
.unwrap();
|
|
|
|
let crdb_id = db.create_sub_channel("crdb", zed_id, a_id).await.unwrap();
|
|
let livestreaming_id = db
|
|
.create_sub_channel("livestreaming", zed_id, a_id)
|
|
.await
|
|
.unwrap();
|
|
let replace_id = db
|
|
.create_sub_channel("replace", zed_id, a_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let (members, _) = db
|
|
.get_channel_participant_details(replace_id, "", 10, a_id)
|
|
.await
|
|
.unwrap();
|
|
let ids = members
|
|
.into_iter()
|
|
.map(|m| UserId::from_proto(m.user_id))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(ids, &[a_id, b_id]);
|
|
|
|
let rust_id = db.create_root_channel("rust", a_id).await.unwrap();
|
|
let cargo_id = db.create_sub_channel("cargo", rust_id, a_id).await.unwrap();
|
|
|
|
let cargo_ra_id = db
|
|
.create_sub_channel("cargo-ra", cargo_id, a_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = db.get_channels_for_user(a_id).await.unwrap();
|
|
assert_channel_tree_matches(
|
|
result.channels,
|
|
channel_tree(&[
|
|
(zed_id, &[], "zed"),
|
|
(crdb_id, &[zed_id], "crdb"),
|
|
(livestreaming_id, &[zed_id], "livestreaming"),
|
|
(replace_id, &[zed_id], "replace"),
|
|
(rust_id, &[], "rust"),
|
|
(cargo_id, &[rust_id], "cargo"),
|
|
(cargo_ra_id, &[rust_id, cargo_id], "cargo-ra"),
|
|
]),
|
|
);
|
|
|
|
let result = db.get_channels_for_user(b_id).await.unwrap();
|
|
assert_channel_tree_matches(
|
|
result.channels,
|
|
channel_tree(&[
|
|
(zed_id, &[], "zed"),
|
|
(crdb_id, &[zed_id], "crdb"),
|
|
(livestreaming_id, &[zed_id], "livestreaming"),
|
|
(replace_id, &[zed_id], "replace"),
|
|
]),
|
|
);
|
|
|
|
// Update member permissions
|
|
let set_subchannel_admin = db
|
|
.set_channel_member_role(crdb_id, a_id, b_id, ChannelRole::Admin)
|
|
.await;
|
|
assert!(set_subchannel_admin.is_err());
|
|
let set_channel_admin = db
|
|
.set_channel_member_role(zed_id, a_id, b_id, ChannelRole::Admin)
|
|
.await;
|
|
assert!(set_channel_admin.is_ok());
|
|
|
|
let result = db.get_channels_for_user(b_id).await.unwrap();
|
|
assert_channel_tree_matches(
|
|
result.channels,
|
|
channel_tree(&[
|
|
(zed_id, &[], "zed"),
|
|
(crdb_id, &[zed_id], "crdb"),
|
|
(livestreaming_id, &[zed_id], "livestreaming"),
|
|
(replace_id, &[zed_id], "replace"),
|
|
]),
|
|
);
|
|
|
|
// Remove a single channel
|
|
db.delete_channel(crdb_id, a_id).await.unwrap();
|
|
assert!(db.get_channel(crdb_id, a_id).await.is_err());
|
|
|
|
// Remove a channel tree
|
|
let (_, mut channel_ids) = db.delete_channel(rust_id, a_id).await.unwrap();
|
|
channel_ids.sort();
|
|
assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]);
|
|
|
|
assert!(db.get_channel(rust_id, a_id).await.is_err());
|
|
assert!(db.get_channel(cargo_id, a_id).await.is_err());
|
|
assert!(db.get_channel(cargo_ra_id, a_id).await.is_err());
|
|
}
|
|
|
|
test_both_dbs!(
|
|
test_joining_channels,
|
|
test_joining_channels_postgres,
|
|
test_joining_channels_sqlite
|
|
);
|
|
|
|
async fn test_joining_channels(db: &Arc<Database>) {
|
|
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
|
|
|
let user_1 = new_test_user(db, "user1@example.com").await;
|
|
let user_2 = new_test_user(db, "user2@example.com").await;
|
|
|
|
let channel_1 = db.create_root_channel("channel_1", user_1).await.unwrap();
|
|
|
|
// can join a room with membership to its channel
|
|
let (joined_room, _, _) = db
|
|
.join_channel(channel_1, user_1, ConnectionId { owner_id, id: 1 })
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(joined_room.room.participants.len(), 1);
|
|
|
|
let room_id = RoomId::from_proto(joined_room.room.id);
|
|
drop(joined_room);
|
|
// cannot join a room without membership to its channel
|
|
assert!(
|
|
db.join_room(room_id, user_2, ConnectionId { owner_id, id: 1 },)
|
|
.await
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
test_both_dbs!(
|
|
test_channel_invites,
|
|
test_channel_invites_postgres,
|
|
test_channel_invites_sqlite
|
|
);
|
|
|
|
async fn test_channel_invites(db: &Arc<Database>) {
|
|
db.create_server("test").await.unwrap();
|
|
|
|
let user_1 = new_test_user(db, "user1@example.com").await;
|
|
let user_2 = new_test_user(db, "user2@example.com").await;
|
|
let user_3 = new_test_user(db, "user3@example.com").await;
|
|
|
|
let channel_1_1 = db.create_root_channel("channel_1", user_1).await.unwrap();
|
|
|
|
let channel_1_2 = db.create_root_channel("channel_2", user_1).await.unwrap();
|
|
|
|
db.invite_channel_member(channel_1_1, user_2, user_1, ChannelRole::Member)
|
|
.await
|
|
.unwrap();
|
|
db.invite_channel_member(channel_1_2, user_2, user_1, ChannelRole::Member)
|
|
.await
|
|
.unwrap();
|
|
db.invite_channel_member(channel_1_1, user_3, user_1, ChannelRole::Admin)
|
|
.await
|
|
.unwrap();
|
|
|
|
let user_2_invites = db
|
|
.get_channels_for_user(user_2)
|
|
.await
|
|
.unwrap()
|
|
.invited_channels
|
|
.into_iter()
|
|
.map(|channel| channel.id)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(user_2_invites, &[channel_1_1, channel_1_2]);
|
|
|
|
let user_3_invites = db
|
|
.get_channels_for_user(user_3)
|
|
.await
|
|
.unwrap()
|
|
.invited_channels
|
|
.into_iter()
|
|
.map(|channel| channel.id)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(user_3_invites, &[channel_1_1]);
|
|
|
|
let (mut members, _) = db
|
|
.get_channel_participant_details(channel_1_1, "", 100, user_1)
|
|
.await
|
|
.unwrap();
|
|
|
|
members.sort_by_key(|member| member.user_id);
|
|
assert_eq!(
|
|
members,
|
|
&[
|
|
proto::ChannelMember {
|
|
user_id: user_1.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Admin.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: user_2.to_proto(),
|
|
kind: proto::channel_member::Kind::Invitee.into(),
|
|
role: proto::ChannelRole::Member.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: user_3.to_proto(),
|
|
kind: proto::channel_member::Kind::Invitee.into(),
|
|
role: proto::ChannelRole::Admin.into(),
|
|
},
|
|
]
|
|
);
|
|
|
|
db.respond_to_channel_invite(channel_1_1, user_2, true)
|
|
.await
|
|
.unwrap();
|
|
|
|
let channel_1_3 = db
|
|
.create_sub_channel("channel_3", channel_1_1, user_1)
|
|
.await
|
|
.unwrap();
|
|
|
|
let (members, _) = db
|
|
.get_channel_participant_details(channel_1_3, "", 100, user_1)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
members,
|
|
&[
|
|
proto::ChannelMember {
|
|
user_id: user_1.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Admin.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: user_3.to_proto(),
|
|
kind: proto::channel_member::Kind::Invitee.into(),
|
|
role: proto::ChannelRole::Admin.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: user_2.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Member.into(),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
test_both_dbs!(
|
|
test_channel_renames,
|
|
test_channel_renames_postgres,
|
|
test_channel_renames_sqlite
|
|
);
|
|
|
|
async fn test_channel_renames(db: &Arc<Database>) {
|
|
db.create_server("test").await.unwrap();
|
|
|
|
let user_1 = db
|
|
.create_user(
|
|
"user1@example.com",
|
|
None,
|
|
false,
|
|
NewUserParams {
|
|
github_login: "user1".into(),
|
|
github_user_id: 5,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.user_id;
|
|
|
|
let user_2 = db
|
|
.create_user(
|
|
"user2@example.com",
|
|
None,
|
|
false,
|
|
NewUserParams {
|
|
github_login: "user2".into(),
|
|
github_user_id: 6,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.user_id;
|
|
|
|
let zed_id = db.create_root_channel("zed", user_1).await.unwrap();
|
|
|
|
db.rename_channel(zed_id, user_1, "#zed-archive")
|
|
.await
|
|
.unwrap();
|
|
|
|
let channel = db.get_channel(zed_id, user_1).await.unwrap();
|
|
assert_eq!(channel.name, "zed-archive");
|
|
|
|
let non_permissioned_rename = db.rename_channel(zed_id, user_2, "hacked-lol").await;
|
|
assert!(non_permissioned_rename.is_err());
|
|
|
|
let bad_name_rename = db.rename_channel(zed_id, user_1, "#").await;
|
|
assert!(bad_name_rename.is_err())
|
|
}
|
|
|
|
test_both_dbs!(
|
|
test_db_channel_moving,
|
|
test_db_channel_moving_postgres,
|
|
test_db_channel_moving_sqlite
|
|
);
|
|
|
|
async fn test_db_channel_moving(db: &Arc<Database>) {
|
|
let a_id = db
|
|
.create_user(
|
|
"user1@example.com",
|
|
None,
|
|
false,
|
|
NewUserParams {
|
|
github_login: "user1".into(),
|
|
github_user_id: 5,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.user_id;
|
|
|
|
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
|
|
|
|
let crdb_id = db.create_sub_channel("crdb", zed_id, a_id).await.unwrap();
|
|
|
|
let gpui2_id = db.create_sub_channel("gpui2", zed_id, a_id).await.unwrap();
|
|
|
|
let livestreaming_id = db
|
|
.create_sub_channel("livestreaming", crdb_id, a_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let livestreaming_sub_id = db
|
|
.create_sub_channel("livestreaming_sub", livestreaming_id, a_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
// sanity check
|
|
// /- gpui2
|
|
// zed -- crdb - livestreaming - livestreaming_sub
|
|
let result = db.get_channels_for_user(a_id).await.unwrap();
|
|
assert_channel_tree(
|
|
result.channels,
|
|
&[
|
|
(zed_id, &[]),
|
|
(crdb_id, &[zed_id]),
|
|
(livestreaming_id, &[zed_id, crdb_id]),
|
|
(livestreaming_sub_id, &[zed_id, crdb_id, livestreaming_id]),
|
|
(gpui2_id, &[zed_id]),
|
|
],
|
|
);
|
|
|
|
// Check that we can do a simple leaf -> leaf move
|
|
db.move_channel(livestreaming_sub_id, crdb_id, a_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
// /- gpui2
|
|
// zed -- crdb -- livestreaming
|
|
// \- livestreaming_sub
|
|
let result = db.get_channels_for_user(a_id).await.unwrap();
|
|
assert_channel_tree(
|
|
result.channels,
|
|
&[
|
|
(zed_id, &[]),
|
|
(crdb_id, &[zed_id]),
|
|
(livestreaming_id, &[zed_id, crdb_id]),
|
|
(livestreaming_sub_id, &[zed_id, crdb_id]),
|
|
(gpui2_id, &[zed_id]),
|
|
],
|
|
);
|
|
|
|
// Check that we can move a whole subtree at once
|
|
db.move_channel(crdb_id, gpui2_id, a_id).await.unwrap();
|
|
|
|
// zed -- gpui2 -- crdb -- livestreaming
|
|
// \- livestreaming_sub
|
|
let result = db.get_channels_for_user(a_id).await.unwrap();
|
|
assert_channel_tree(
|
|
result.channels,
|
|
&[
|
|
(zed_id, &[]),
|
|
(gpui2_id, &[zed_id]),
|
|
(crdb_id, &[zed_id, gpui2_id]),
|
|
(livestreaming_id, &[zed_id, gpui2_id, crdb_id]),
|
|
(livestreaming_sub_id, &[zed_id, gpui2_id, crdb_id]),
|
|
],
|
|
);
|
|
}
|
|
|
|
test_both_dbs!(
|
|
test_channel_reordering,
|
|
test_channel_reordering_postgres,
|
|
test_channel_reordering_sqlite
|
|
);
|
|
|
|
async fn test_channel_reordering(db: &Arc<Database>) {
|
|
let admin_id = db
|
|
.create_user(
|
|
"admin@example.com",
|
|
None,
|
|
false,
|
|
NewUserParams {
|
|
github_login: "admin".into(),
|
|
github_user_id: 1,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.user_id;
|
|
|
|
let user_id = db
|
|
.create_user(
|
|
"user@example.com",
|
|
None,
|
|
false,
|
|
NewUserParams {
|
|
github_login: "user".into(),
|
|
github_user_id: 2,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.user_id;
|
|
|
|
// Create a root channel with some sub-channels
|
|
let root_id = db.create_root_channel("root", admin_id).await.unwrap();
|
|
|
|
// Invite user to root channel so they can see the sub-channels
|
|
db.invite_channel_member(root_id, user_id, admin_id, ChannelRole::Member)
|
|
.await
|
|
.unwrap();
|
|
db.respond_to_channel_invite(root_id, user_id, true)
|
|
.await
|
|
.unwrap();
|
|
|
|
let alpha_id = db
|
|
.create_sub_channel("alpha", root_id, admin_id)
|
|
.await
|
|
.unwrap();
|
|
let beta_id = db
|
|
.create_sub_channel("beta", root_id, admin_id)
|
|
.await
|
|
.unwrap();
|
|
let gamma_id = db
|
|
.create_sub_channel("gamma", root_id, admin_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Initial order should be: root, alpha (order=1), beta (order=2), gamma (order=3)
|
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
|
assert_channel_tree_order(
|
|
result.channels,
|
|
&[
|
|
(root_id, &[], 1),
|
|
(alpha_id, &[root_id], 1),
|
|
(beta_id, &[root_id], 2),
|
|
(gamma_id, &[root_id], 3),
|
|
],
|
|
);
|
|
|
|
// Test moving beta up (should swap with alpha)
|
|
let updated_channels = db
|
|
.reorder_channel(beta_id, reorder_channel::Direction::Up, admin_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify that beta and alpha were returned as updated
|
|
assert_eq!(updated_channels.len(), 2);
|
|
let updated_ids: std::collections::HashSet<_> = updated_channels.iter().map(|c| c.id).collect();
|
|
assert!(updated_ids.contains(&alpha_id));
|
|
assert!(updated_ids.contains(&beta_id));
|
|
|
|
// Now order should be: root, beta (order=1), alpha (order=2), gamma (order=3)
|
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
|
assert_channel_tree_order(
|
|
result.channels,
|
|
&[
|
|
(root_id, &[], 1),
|
|
(beta_id, &[root_id], 1),
|
|
(alpha_id, &[root_id], 2),
|
|
(gamma_id, &[root_id], 3),
|
|
],
|
|
);
|
|
|
|
// Test moving gamma down (should be no-op since it's already last)
|
|
let updated_channels = db
|
|
.reorder_channel(gamma_id, reorder_channel::Direction::Down, admin_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Should return just nothing
|
|
assert_eq!(updated_channels.len(), 0);
|
|
|
|
// Test moving alpha down (should swap with gamma)
|
|
let updated_channels = db
|
|
.reorder_channel(alpha_id, reorder_channel::Direction::Down, admin_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify that alpha and gamma were returned as updated
|
|
assert_eq!(updated_channels.len(), 2);
|
|
let updated_ids: std::collections::HashSet<_> = updated_channels.iter().map(|c| c.id).collect();
|
|
assert!(updated_ids.contains(&alpha_id));
|
|
assert!(updated_ids.contains(&gamma_id));
|
|
|
|
// Now order should be: root, beta (order=1), gamma (order=2), alpha (order=3)
|
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
|
assert_channel_tree_order(
|
|
result.channels,
|
|
&[
|
|
(root_id, &[], 1),
|
|
(beta_id, &[root_id], 1),
|
|
(gamma_id, &[root_id], 2),
|
|
(alpha_id, &[root_id], 3),
|
|
],
|
|
);
|
|
|
|
// Test that non-admin cannot reorder
|
|
let reorder_result = db
|
|
.reorder_channel(beta_id, reorder_channel::Direction::Up, user_id)
|
|
.await;
|
|
assert!(reorder_result.is_err());
|
|
|
|
// Test moving beta up (should be no-op since it's already first)
|
|
let updated_channels = db
|
|
.reorder_channel(beta_id, reorder_channel::Direction::Up, admin_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Should return nothing
|
|
assert_eq!(updated_channels.len(), 0);
|
|
|
|
// Adding a channel to an existing ordering should add it to the end
|
|
let delta_id = db
|
|
.create_sub_channel("delta", root_id, admin_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
|
assert_channel_tree_order(
|
|
result.channels,
|
|
&[
|
|
(root_id, &[], 1),
|
|
(beta_id, &[root_id], 1),
|
|
(gamma_id, &[root_id], 2),
|
|
(alpha_id, &[root_id], 3),
|
|
(delta_id, &[root_id], 4),
|
|
],
|
|
);
|
|
|
|
// And moving a channel into an existing ordering should add it to the end
|
|
let eta_id = db
|
|
.create_sub_channel("eta", delta_id, admin_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
|
assert_channel_tree_order(
|
|
result.channels,
|
|
&[
|
|
(root_id, &[], 1),
|
|
(beta_id, &[root_id], 1),
|
|
(gamma_id, &[root_id], 2),
|
|
(alpha_id, &[root_id], 3),
|
|
(delta_id, &[root_id], 4),
|
|
(eta_id, &[root_id, delta_id], 1),
|
|
],
|
|
);
|
|
|
|
db.move_channel(eta_id, root_id, admin_id).await.unwrap();
|
|
let result = db.get_channels_for_user(admin_id).await.unwrap();
|
|
assert_channel_tree_order(
|
|
result.channels,
|
|
&[
|
|
(root_id, &[], 1),
|
|
(beta_id, &[root_id], 1),
|
|
(gamma_id, &[root_id], 2),
|
|
(alpha_id, &[root_id], 3),
|
|
(delta_id, &[root_id], 4),
|
|
(eta_id, &[root_id], 5),
|
|
],
|
|
);
|
|
}
|
|
|
|
test_both_dbs!(
|
|
test_db_channel_moving_bugs,
|
|
test_db_channel_moving_bugs_postgres,
|
|
test_db_channel_moving_bugs_sqlite
|
|
);
|
|
|
|
async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
|
|
let user_id = db
|
|
.create_user(
|
|
"user1@example.com",
|
|
None,
|
|
false,
|
|
NewUserParams {
|
|
github_login: "user1".into(),
|
|
github_user_id: 5,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.user_id;
|
|
|
|
let zed_id = db.create_root_channel("zed", user_id).await.unwrap();
|
|
|
|
let projects_id = db
|
|
.create_sub_channel("projects", zed_id, user_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let livestreaming_id = db
|
|
.create_sub_channel("livestreaming", projects_id, user_id)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = db.get_channels_for_user(user_id).await.unwrap();
|
|
assert_channel_tree(
|
|
result.channels,
|
|
&[
|
|
(zed_id, &[]),
|
|
(projects_id, &[zed_id]),
|
|
(livestreaming_id, &[zed_id, projects_id]),
|
|
],
|
|
);
|
|
|
|
// Can't move a channel into its ancestor
|
|
db.move_channel(projects_id, livestreaming_id, user_id)
|
|
.await
|
|
.unwrap_err();
|
|
let result = db.get_channels_for_user(user_id).await.unwrap();
|
|
assert_channel_tree(
|
|
result.channels,
|
|
&[
|
|
(zed_id, &[]),
|
|
(projects_id, &[zed_id]),
|
|
(livestreaming_id, &[zed_id, projects_id]),
|
|
],
|
|
);
|
|
|
|
// Can't un-root a root channel
|
|
db.move_channel(zed_id, livestreaming_id, user_id)
|
|
.await
|
|
.unwrap_err();
|
|
let result = db.get_channels_for_user(user_id).await.unwrap();
|
|
assert_channel_tree(
|
|
result.channels,
|
|
&[
|
|
(zed_id, &[]),
|
|
(projects_id, &[zed_id]),
|
|
(livestreaming_id, &[zed_id, projects_id]),
|
|
],
|
|
);
|
|
}
|
|
|
|
test_both_dbs!(
|
|
test_user_is_channel_participant,
|
|
test_user_is_channel_participant_postgres,
|
|
test_user_is_channel_participant_sqlite
|
|
);
|
|
|
|
async fn test_user_is_channel_participant(db: &Arc<Database>) {
|
|
let admin = new_test_user(db, "admin@example.com").await;
|
|
let member = new_test_user(db, "member@example.com").await;
|
|
let guest = new_test_user(db, "guest@example.com").await;
|
|
|
|
let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
|
|
let internal_channel_id = db
|
|
.create_sub_channel("active", zed_channel, admin)
|
|
.await
|
|
.unwrap();
|
|
let public_channel_id = db
|
|
.create_sub_channel("vim", zed_channel, admin)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
|
|
.await
|
|
.unwrap();
|
|
db.set_channel_visibility(
|
|
public_channel_id,
|
|
crate::db::ChannelVisibility::Public,
|
|
admin,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.invite_channel_member(zed_channel, member, admin, ChannelRole::Member)
|
|
.await
|
|
.unwrap();
|
|
db.invite_channel_member(zed_channel, guest, admin, ChannelRole::Guest)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.respond_to_channel_invite(zed_channel, member, true)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.transaction(|tx| async move {
|
|
db.check_user_is_channel_participant(
|
|
&db.get_channel_internal(public_channel_id, &tx).await?,
|
|
admin,
|
|
&tx,
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.unwrap();
|
|
db.transaction(|tx| async move {
|
|
db.check_user_is_channel_participant(
|
|
&db.get_channel_internal(public_channel_id, &tx).await?,
|
|
member,
|
|
&tx,
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let (mut members, _) = db
|
|
.get_channel_participant_details(public_channel_id, "", 100, admin)
|
|
.await
|
|
.unwrap();
|
|
|
|
members.sort_by_key(|member| member.user_id);
|
|
|
|
assert_eq!(
|
|
members,
|
|
&[
|
|
proto::ChannelMember {
|
|
user_id: admin.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Admin.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: member.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Member.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: guest.to_proto(),
|
|
kind: proto::channel_member::Kind::Invitee.into(),
|
|
role: proto::ChannelRole::Guest.into(),
|
|
},
|
|
]
|
|
);
|
|
|
|
db.respond_to_channel_invite(zed_channel, guest, true)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.transaction(|tx| async move {
|
|
db.check_user_is_channel_participant(
|
|
&db.get_channel_internal(public_channel_id, &tx).await?,
|
|
guest,
|
|
&tx,
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let channels = db.get_channels_for_user(guest).await.unwrap().channels;
|
|
assert_channel_tree(
|
|
channels,
|
|
&[(zed_channel, &[]), (public_channel_id, &[zed_channel])],
|
|
);
|
|
let channels = db.get_channels_for_user(member).await.unwrap().channels;
|
|
assert_channel_tree(
|
|
channels,
|
|
&[
|
|
(zed_channel, &[]),
|
|
(internal_channel_id, &[zed_channel]),
|
|
(public_channel_id, &[zed_channel]),
|
|
],
|
|
);
|
|
|
|
db.set_channel_member_role(zed_channel, admin, guest, ChannelRole::Banned)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
db.transaction(|tx| async move {
|
|
db.check_user_is_channel_participant(
|
|
&db.get_channel_internal(public_channel_id, &tx)
|
|
.await
|
|
.unwrap(),
|
|
guest,
|
|
&tx,
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.is_err()
|
|
);
|
|
|
|
let (mut members, _) = db
|
|
.get_channel_participant_details(public_channel_id, "", 100, admin)
|
|
.await
|
|
.unwrap();
|
|
|
|
members.sort_by_key(|member| member.user_id);
|
|
|
|
assert_eq!(
|
|
members,
|
|
&[
|
|
proto::ChannelMember {
|
|
user_id: admin.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Admin.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: member.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Member.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: guest.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Banned.into(),
|
|
},
|
|
]
|
|
);
|
|
|
|
db.remove_channel_member(zed_channel, guest, admin)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.invite_channel_member(zed_channel, guest, admin, ChannelRole::Guest)
|
|
.await
|
|
.unwrap();
|
|
|
|
// currently people invited to parent channels are not shown here
|
|
let (mut members, _) = db
|
|
.get_channel_participant_details(public_channel_id, "", 100, admin)
|
|
.await
|
|
.unwrap();
|
|
|
|
members.sort_by_key(|member| member.user_id);
|
|
|
|
assert_eq!(
|
|
members,
|
|
&[
|
|
proto::ChannelMember {
|
|
user_id: admin.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Admin.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: member.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Member.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: guest.to_proto(),
|
|
kind: proto::channel_member::Kind::Invitee.into(),
|
|
role: proto::ChannelRole::Guest.into(),
|
|
},
|
|
]
|
|
);
|
|
|
|
db.respond_to_channel_invite(zed_channel, guest, true)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.transaction(|tx| async move {
|
|
db.check_user_is_channel_participant(
|
|
&db.get_channel_internal(zed_channel, &tx).await.unwrap(),
|
|
guest,
|
|
&tx,
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
db.transaction(|tx| async move {
|
|
db.check_user_is_channel_participant(
|
|
&db.get_channel_internal(internal_channel_id, &tx)
|
|
.await
|
|
.unwrap(),
|
|
guest,
|
|
&tx,
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.is_err(),
|
|
);
|
|
|
|
db.transaction(|tx| async move {
|
|
db.check_user_is_channel_participant(
|
|
&db.get_channel_internal(public_channel_id, &tx)
|
|
.await
|
|
.unwrap(),
|
|
guest,
|
|
&tx,
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let (mut members, _) = db
|
|
.get_channel_participant_details(public_channel_id, "", 100, admin)
|
|
.await
|
|
.unwrap();
|
|
|
|
members.sort_by_key(|member| member.user_id);
|
|
|
|
assert_eq!(
|
|
members,
|
|
&[
|
|
proto::ChannelMember {
|
|
user_id: admin.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Admin.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: member.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Member.into(),
|
|
},
|
|
proto::ChannelMember {
|
|
user_id: guest.to_proto(),
|
|
kind: proto::channel_member::Kind::Member.into(),
|
|
role: proto::ChannelRole::Guest.into(),
|
|
},
|
|
]
|
|
);
|
|
|
|
let channels = db.get_channels_for_user(guest).await.unwrap().channels;
|
|
assert_channel_tree(
|
|
channels,
|
|
&[(zed_channel, &[]), (public_channel_id, &[zed_channel])],
|
|
)
|
|
}
|
|
|
|
test_both_dbs!(
|
|
test_guest_access,
|
|
test_guest_access_postgres,
|
|
test_guest_access_sqlite
|
|
);
|
|
|
|
async fn test_guest_access(db: &Arc<Database>) {
|
|
let server = db.create_server("test").await.unwrap();
|
|
|
|
let admin = new_test_user(db, "admin@example.com").await;
|
|
let guest = new_test_user(db, "guest@example.com").await;
|
|
let guest_connection = new_test_connection(server);
|
|
|
|
let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
|
|
db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
db.join_channel_chat(zed_channel, guest_connection, guest)
|
|
.await
|
|
.is_err()
|
|
);
|
|
|
|
db.join_channel(zed_channel, guest, guest_connection)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
db.join_channel_chat(zed_channel, guest_connection, guest)
|
|
.await
|
|
.is_ok()
|
|
)
|
|
}
|
|
|
|
#[track_caller]
|
|
fn assert_channel_tree(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId])]) {
|
|
let actual = actual
|
|
.iter()
|
|
.map(|channel| (channel.id, channel.parent_path.as_slice()))
|
|
.collect::<HashSet<_>>();
|
|
let expected = expected
|
|
.iter()
|
|
.map(|(id, parents)| (*id, *parents))
|
|
.collect::<HashSet<_>>();
|
|
pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths");
|
|
}
|
|
|
|
#[track_caller]
|
|
fn assert_channel_tree_order(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId], i32)]) {
|
|
let actual = actual
|
|
.iter()
|
|
.map(|channel| {
|
|
(
|
|
channel.id,
|
|
channel.parent_path.as_slice(),
|
|
channel.channel_order,
|
|
)
|
|
})
|
|
.collect::<HashSet<_>>();
|
|
let expected = expected
|
|
.iter()
|
|
.map(|(id, parents, order)| (*id, *parents, *order))
|
|
.collect::<HashSet<_>>();
|
|
pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths");
|
|
}
|