Start work on exposing which channels the user has admin rights to
This commit is contained in:
parent
95b1ab9574
commit
7a04ee3b71
7 changed files with 70 additions and 36 deletions
|
@ -26,6 +26,7 @@ pub struct Channel {
|
||||||
pub id: ChannelId,
|
pub id: ChannelId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub parent_id: Option<ChannelId>,
|
pub parent_id: Option<ChannelId>,
|
||||||
|
pub user_is_admin: bool,
|
||||||
pub depth: usize,
|
pub depth: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -247,6 +248,7 @@ impl ChannelStore {
|
||||||
Arc::new(Channel {
|
Arc::new(Channel {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_is_admin: false,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
}),
|
}),
|
||||||
|
@ -267,6 +269,7 @@ impl ChannelStore {
|
||||||
Arc::new(Channel {
|
Arc::new(Channel {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_is_admin: channel.user_is_admin,
|
||||||
parent_id: Some(parent_id),
|
parent_id: Some(parent_id),
|
||||||
depth,
|
depth,
|
||||||
}),
|
}),
|
||||||
|
@ -278,6 +281,7 @@ impl ChannelStore {
|
||||||
Arc::new(Channel {
|
Arc::new(Channel {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_is_admin: channel.user_is_admin,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -18,11 +18,13 @@ fn test_update_channels(cx: &mut AppContext) {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "b".to_string(),
|
name: "b".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: true,
|
||||||
},
|
},
|
||||||
proto::Channel {
|
proto::Channel {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "a".to_string(),
|
name: "a".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
@ -33,8 +35,8 @@ fn test_update_channels(cx: &mut AppContext) {
|
||||||
&channel_store,
|
&channel_store,
|
||||||
&[
|
&[
|
||||||
//
|
//
|
||||||
(0, "a"),
|
(0, "a", true),
|
||||||
(0, "b"),
|
(0, "b", false),
|
||||||
],
|
],
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
@ -47,11 +49,13 @@ fn test_update_channels(cx: &mut AppContext) {
|
||||||
id: 3,
|
id: 3,
|
||||||
name: "x".to_string(),
|
name: "x".to_string(),
|
||||||
parent_id: Some(1),
|
parent_id: Some(1),
|
||||||
|
user_is_admin: false,
|
||||||
},
|
},
|
||||||
proto::Channel {
|
proto::Channel {
|
||||||
id: 4,
|
id: 4,
|
||||||
name: "y".to_string(),
|
name: "y".to_string(),
|
||||||
parent_id: Some(2),
|
parent_id: Some(2),
|
||||||
|
user_is_admin: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
@ -61,11 +65,10 @@ fn test_update_channels(cx: &mut AppContext) {
|
||||||
assert_channels(
|
assert_channels(
|
||||||
&channel_store,
|
&channel_store,
|
||||||
&[
|
&[
|
||||||
//
|
(0, "a", true),
|
||||||
(0, "a"),
|
(1, "y", true),
|
||||||
(1, "y"),
|
(0, "b", false),
|
||||||
(0, "b"),
|
(1, "x", false),
|
||||||
(1, "x"),
|
|
||||||
],
|
],
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
@ -81,14 +84,14 @@ fn update_channels(
|
||||||
|
|
||||||
fn assert_channels(
|
fn assert_channels(
|
||||||
channel_store: &ModelHandle<ChannelStore>,
|
channel_store: &ModelHandle<ChannelStore>,
|
||||||
expected_channels: &[(usize, &str)],
|
expected_channels: &[(usize, &str, bool)],
|
||||||
cx: &AppContext,
|
cx: &AppContext,
|
||||||
) {
|
) {
|
||||||
channel_store.read_with(cx, |store, _| {
|
channel_store.read_with(cx, |store, _| {
|
||||||
let actual = store
|
let actual = store
|
||||||
.channels()
|
.channels()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| (c.depth, c.name.as_str()))
|
.map(|c| (c.depth, c.name.as_str(), c.user_is_admin))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(actual, expected_channels);
|
assert_eq!(actual, expected_channels);
|
||||||
});
|
});
|
||||||
|
|
|
@ -3385,6 +3385,7 @@ impl Database {
|
||||||
.map(|channel| Channel {
|
.map(|channel| Channel {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_is_admin: false,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
@ -3401,20 +3402,21 @@ impl Database {
|
||||||
self.transaction(|tx| async move {
|
self.transaction(|tx| async move {
|
||||||
let tx = tx;
|
let tx = tx;
|
||||||
|
|
||||||
let starting_channel_ids: Vec<ChannelId> = channel_member::Entity::find()
|
let channel_memberships = channel_member::Entity::find()
|
||||||
.filter(
|
.filter(
|
||||||
channel_member::Column::UserId
|
channel_member::Column::UserId
|
||||||
.eq(user_id)
|
.eq(user_id)
|
||||||
.and(channel_member::Column::Accepted.eq(true)),
|
.and(channel_member::Column::Accepted.eq(true)),
|
||||||
)
|
)
|
||||||
.select_only()
|
|
||||||
.column(channel_member::Column::ChannelId)
|
|
||||||
.into_values::<_, QueryChannelIds>()
|
|
||||||
.all(&*tx)
|
.all(&*tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let admin_channel_ids = channel_memberships
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| m.admin.then_some(m.channel_id))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
let parents_by_child_id = self
|
let parents_by_child_id = self
|
||||||
.get_channel_descendants(starting_channel_ids, &*tx)
|
.get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut channels = Vec::with_capacity(parents_by_child_id.len());
|
let mut channels = Vec::with_capacity(parents_by_child_id.len());
|
||||||
|
@ -3428,6 +3430,7 @@ impl Database {
|
||||||
channels.push(Channel {
|
channels.push(Channel {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
user_is_admin: admin_channel_ids.contains(&row.id),
|
||||||
parent_id: parents_by_child_id.get(&row.id).copied().flatten(),
|
parent_id: parents_by_child_id.get(&row.id).copied().flatten(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -3627,7 +3630,7 @@ impl Database {
|
||||||
r#"
|
r#"
|
||||||
WITH RECURSIVE channel_tree(child_id, parent_id) AS (
|
WITH RECURSIVE channel_tree(child_id, parent_id) AS (
|
||||||
SELECT root_ids.column1 as child_id, CAST(NULL as INTEGER) as parent_id
|
SELECT root_ids.column1 as child_id, CAST(NULL as INTEGER) as parent_id
|
||||||
FROM (VALUES {}) as root_ids
|
FROM (VALUES {values}) as root_ids
|
||||||
UNION
|
UNION
|
||||||
SELECT channel_parents.child_id, channel_parents.parent_id
|
SELECT channel_parents.child_id, channel_parents.parent_id
|
||||||
FROM channel_parents, channel_tree
|
FROM channel_parents, channel_tree
|
||||||
|
@ -3637,7 +3640,6 @@ impl Database {
|
||||||
FROM channel_tree
|
FROM channel_tree
|
||||||
ORDER BY child_id, parent_id IS NOT NULL
|
ORDER BY child_id, parent_id IS NOT NULL
|
||||||
"#,
|
"#,
|
||||||
values
|
|
||||||
);
|
);
|
||||||
|
|
||||||
#[derive(FromQueryResult, Debug, PartialEq)]
|
#[derive(FromQueryResult, Debug, PartialEq)]
|
||||||
|
@ -3663,14 +3665,29 @@ impl Database {
|
||||||
Ok(parents_by_child_id)
|
Ok(parents_by_child_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_channel(&self, channel_id: ChannelId) -> Result<Option<Channel>> {
|
pub async fn get_channel(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<Option<Channel>> {
|
||||||
self.transaction(|tx| async move {
|
self.transaction(|tx| async move {
|
||||||
let tx = tx;
|
let tx = tx;
|
||||||
let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?;
|
let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?;
|
||||||
|
let user_is_admin = channel_member::Entity::find()
|
||||||
|
.filter(
|
||||||
|
channel_member::Column::ChannelId
|
||||||
|
.eq(channel_id)
|
||||||
|
.and(channel_member::Column::UserId.eq(user_id))
|
||||||
|
.and(channel_member::Column::Admin.eq(true)),
|
||||||
|
)
|
||||||
|
.count(&*tx)
|
||||||
|
.await?
|
||||||
|
> 0;
|
||||||
|
|
||||||
Ok(channel.map(|channel| Channel {
|
Ok(channel.map(|channel| Channel {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_is_admin,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
@ -3942,6 +3959,7 @@ pub struct NewUserResult {
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
pub id: ChannelId,
|
pub id: ChannelId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub user_is_admin: bool,
|
||||||
pub parent_id: Option<ChannelId>,
|
pub parent_id: Option<ChannelId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4199,11 +4217,6 @@ pub struct WorktreeSettingsFile {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
|
||||||
enum QueryChannelIds {
|
|
||||||
ChannelId,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||||
enum QueryUserIds {
|
enum QueryUserIds {
|
||||||
UserId,
|
UserId,
|
||||||
|
|
|
@ -960,43 +960,50 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, {
|
||||||
id: zed_id,
|
id: zed_id,
|
||||||
name: "zed".to_string(),
|
name: "zed".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: true,
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: crdb_id,
|
id: crdb_id,
|
||||||
name: "crdb".to_string(),
|
name: "crdb".to_string(),
|
||||||
parent_id: Some(zed_id),
|
parent_id: Some(zed_id),
|
||||||
|
user_is_admin: true,
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: livestreaming_id,
|
id: livestreaming_id,
|
||||||
name: "livestreaming".to_string(),
|
name: "livestreaming".to_string(),
|
||||||
parent_id: Some(zed_id),
|
parent_id: Some(zed_id),
|
||||||
|
user_is_admin: true,
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: replace_id,
|
id: replace_id,
|
||||||
name: "replace".to_string(),
|
name: "replace".to_string(),
|
||||||
parent_id: Some(zed_id),
|
parent_id: Some(zed_id),
|
||||||
|
user_is_admin: true,
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: rust_id,
|
id: rust_id,
|
||||||
name: "rust".to_string(),
|
name: "rust".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: true,
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: cargo_id,
|
id: cargo_id,
|
||||||
name: "cargo".to_string(),
|
name: "cargo".to_string(),
|
||||||
parent_id: Some(rust_id),
|
parent_id: Some(rust_id),
|
||||||
|
user_is_admin: true,
|
||||||
},
|
},
|
||||||
Channel {
|
Channel {
|
||||||
id: cargo_ra_id,
|
id: cargo_ra_id,
|
||||||
name: "cargo-ra".to_string(),
|
name: "cargo-ra".to_string(),
|
||||||
parent_id: Some(cargo_id),
|
parent_id: Some(cargo_id),
|
||||||
|
user_is_admin: true,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove a single channel
|
// Remove a single channel
|
||||||
db.remove_channel(crdb_id, a_id).await.unwrap();
|
db.remove_channel(crdb_id, a_id).await.unwrap();
|
||||||
assert!(db.get_channel(crdb_id).await.unwrap().is_none());
|
assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none());
|
||||||
|
|
||||||
// Remove a channel tree
|
// Remove a channel tree
|
||||||
let (mut channel_ids, user_ids) = db.remove_channel(rust_id, a_id).await.unwrap();
|
let (mut channel_ids, user_ids) = db.remove_channel(rust_id, a_id).await.unwrap();
|
||||||
|
@ -1004,9 +1011,9 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, {
|
||||||
assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]);
|
assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]);
|
||||||
assert_eq!(user_ids, &[a_id]);
|
assert_eq!(user_ids, &[a_id]);
|
||||||
|
|
||||||
assert!(db.get_channel(rust_id).await.unwrap().is_none());
|
assert!(db.get_channel(rust_id, a_id).await.unwrap().is_none());
|
||||||
assert!(db.get_channel(cargo_id).await.unwrap().is_none());
|
assert!(db.get_channel(cargo_id, a_id).await.unwrap().is_none());
|
||||||
assert!(db.get_channel(cargo_ra_id).await.unwrap().is_none());
|
assert!(db.get_channel(cargo_ra_id, a_id).await.unwrap().is_none());
|
||||||
});
|
});
|
||||||
|
|
||||||
test_both_dbs!(
|
test_both_dbs!(
|
||||||
|
|
|
@ -2150,6 +2150,7 @@ async fn create_channel(
|
||||||
id: id.to_proto(),
|
id: id.to_proto(),
|
||||||
name: request.name,
|
name: request.name,
|
||||||
parent_id: request.parent_id,
|
parent_id: request.parent_id,
|
||||||
|
user_is_admin: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(parent_id) = parent_id {
|
if let Some(parent_id) = parent_id {
|
||||||
|
@ -2204,7 +2205,7 @@ async fn invite_channel_member(
|
||||||
let db = session.db().await;
|
let db = session.db().await;
|
||||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||||
let channel = db
|
let channel = db
|
||||||
.get_channel(channel_id)
|
.get_channel(channel_id, session.user_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow!("channel not found"))?;
|
.ok_or_else(|| anyhow!("channel not found"))?;
|
||||||
let invitee_id = UserId::from_proto(request.user_id);
|
let invitee_id = UserId::from_proto(request.user_id);
|
||||||
|
@ -2216,6 +2217,7 @@ async fn invite_channel_member(
|
||||||
id: channel.id.to_proto(),
|
id: channel.id.to_proto(),
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: false,
|
||||||
});
|
});
|
||||||
for connection_id in session
|
for connection_id in session
|
||||||
.connection_pool()
|
.connection_pool()
|
||||||
|
@ -2264,12 +2266,12 @@ async fn respond_to_channel_invite(
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let db = session.db().await;
|
let db = session.db().await;
|
||||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||||
let channel = db
|
|
||||||
.get_channel(channel_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| anyhow!("no such channel"))?;
|
|
||||||
db.respond_to_channel_invite(channel_id, session.user_id, request.accept)
|
db.respond_to_channel_invite(channel_id, session.user_id, request.accept)
|
||||||
.await?;
|
.await?;
|
||||||
|
let channel = db
|
||||||
|
.get_channel(channel_id, session.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("no such channel"))?;
|
||||||
|
|
||||||
let mut update = proto::UpdateChannels::default();
|
let mut update = proto::UpdateChannels::default();
|
||||||
update
|
update
|
||||||
|
@ -2279,6 +2281,7 @@ async fn respond_to_channel_invite(
|
||||||
update.channels.push(proto::Channel {
|
update.channels.push(proto::Channel {
|
||||||
id: channel.id.to_proto(),
|
id: channel.id.to_proto(),
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_is_admin: channel.user_is_admin,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -2430,6 +2433,7 @@ fn build_initial_channels_update(
|
||||||
update.channels.push(proto::Channel {
|
update.channels.push(proto::Channel {
|
||||||
id: channel.id.to_proto(),
|
id: channel.id.to_proto(),
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_is_admin: channel.user_is_admin,
|
||||||
parent_id: channel.parent_id.map(|id| id.to_proto()),
|
parent_id: channel.parent_id.map(|id| id.to_proto()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -2447,6 +2451,7 @@ fn build_initial_channels_update(
|
||||||
update.channel_invitations.push(proto::Channel {
|
update.channel_invitations.push(proto::Channel {
|
||||||
id: channel.id.to_proto(),
|
id: channel.id.to_proto(),
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
|
user_is_admin: false,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
|
use crate::tests::{room_participants, RoomParticipants, TestServer};
|
||||||
use call::ActiveCall;
|
use call::ActiveCall;
|
||||||
use client::{Channel, User};
|
use client::{Channel, User};
|
||||||
use gpui::{executor::Deterministic, TestAppContext};
|
use gpui::{executor::Deterministic, TestAppContext};
|
||||||
use rpc::proto;
|
use rpc::proto;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::tests::{room_participants, RoomParticipants};
|
|
||||||
|
|
||||||
use super::TestServer;
|
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_basic_channels(
|
async fn test_basic_channels(
|
||||||
deterministic: Arc<Deterministic>,
|
deterministic: Arc<Deterministic>,
|
||||||
|
@ -35,6 +32,7 @@ async fn test_basic_channels(
|
||||||
id: channel_a_id,
|
id: channel_a_id,
|
||||||
name: "channel-a".to_string(),
|
name: "channel-a".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})]
|
})]
|
||||||
)
|
)
|
||||||
|
@ -69,6 +67,7 @@ async fn test_basic_channels(
|
||||||
id: channel_a_id,
|
id: channel_a_id,
|
||||||
name: "channel-a".to_string(),
|
name: "channel-a".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: false,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})]
|
})]
|
||||||
)
|
)
|
||||||
|
@ -111,6 +110,7 @@ async fn test_basic_channels(
|
||||||
id: channel_a_id,
|
id: channel_a_id,
|
||||||
name: "channel-a".to_string(),
|
name: "channel-a".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: false,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})]
|
})]
|
||||||
)
|
)
|
||||||
|
@ -204,6 +204,7 @@ async fn test_channel_room(
|
||||||
id: zed_id,
|
id: zed_id,
|
||||||
name: "zed".to_string(),
|
name: "zed".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
|
user_is_admin: false,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})]
|
})]
|
||||||
)
|
)
|
||||||
|
|
|
@ -1295,7 +1295,8 @@ message Nonce {
|
||||||
message Channel {
|
message Channel {
|
||||||
uint64 id = 1;
|
uint64 id = 1;
|
||||||
string name = 2;
|
string name = 2;
|
||||||
optional uint64 parent_id = 3;
|
bool user_is_admin = 3;
|
||||||
|
optional uint64 parent_id = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Contact {
|
message Contact {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue