534 lines
16 KiB
Rust
534 lines
16 KiB
Rust
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use sha2::{Digest, Sha256};
|
|
use tauri::State;
|
|
|
|
use crate::node::gossip;
|
|
use crate::node::NodeCommand;
|
|
use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, CommunityMeta, Member};
|
|
use crate::protocol::messages::PeerStatus;
|
|
use crate::AppState;
|
|
|
|
#[tauri::command]
|
|
pub async fn create_community(
|
|
state: State<'_, AppState>,
|
|
name: String,
|
|
description: String,
|
|
) -> Result<CommunityMeta, String> {
|
|
let identity = state.identity.lock().await;
|
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
|
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64;
|
|
|
|
// generate a deterministic community id from name + creator + timestamp
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(name.as_bytes());
|
|
hasher.update(id.peer_id.to_bytes());
|
|
hasher.update(now.to_le_bytes());
|
|
let hash = hasher.finalize();
|
|
let community_id = format!("com_{}", &hex::encode(hash)[..16]);
|
|
|
|
let peer_id_str = id.peer_id.to_string();
|
|
drop(identity);
|
|
|
|
let mut engine = state.crdt_engine.lock().await;
|
|
engine.create_community(&community_id, &name, &description, &peer_id_str)?;
|
|
|
|
let meta = engine.get_community_meta(&community_id)?;
|
|
|
|
// save metadata cache
|
|
let _ = state.storage.save_community_meta(&meta);
|
|
drop(engine);
|
|
|
|
// subscribe to community topics on the p2p node
|
|
let node_handle = state.node_handle.lock().await;
|
|
if let Some(ref handle) = *node_handle {
|
|
let presence_topic = gossip::topic_for_presence(&community_id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Subscribe {
|
|
topic: presence_topic,
|
|
})
|
|
.await;
|
|
|
|
// subscribe to the default general channel
|
|
let engine = state.crdt_engine.lock().await;
|
|
if let Ok(channels) = engine.get_channels(&community_id) {
|
|
for channel in &channels {
|
|
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Subscribe { topic: msg_topic })
|
|
.await;
|
|
|
|
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Subscribe {
|
|
topic: typing_topic,
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
// register on rendezvous so peers joining via invite can discover us
|
|
let namespace = format!("dusk/community/{}", community_id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::RegisterRendezvous { namespace })
|
|
.await;
|
|
}
|
|
|
|
Ok(meta)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn join_community(
|
|
state: State<'_, AppState>,
|
|
invite_code: String,
|
|
) -> Result<CommunityMeta, String> {
|
|
let invite = crate::protocol::community::InviteCode::decode(&invite_code)?;
|
|
|
|
let identity = state.identity.lock().await;
|
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
|
let peer_id_str = id.peer_id.to_string();
|
|
drop(identity);
|
|
|
|
// create a placeholder document that will be backfilled via crdt sync
|
|
// once we connect to existing community members through the relay
|
|
let mut engine = state.crdt_engine.lock().await;
|
|
if !engine.has_community(&invite.community_id) {
|
|
engine.create_community(
|
|
&invite.community_id,
|
|
&invite.community_name,
|
|
"",
|
|
&peer_id_str,
|
|
)?;
|
|
}
|
|
|
|
let meta = engine.get_community_meta(&invite.community_id)?;
|
|
let _ = state.storage.save_community_meta(&meta);
|
|
|
|
// subscribe to gossipsub topics so we receive messages
|
|
let channels = engine
|
|
.get_channels(&invite.community_id)
|
|
.unwrap_or_default();
|
|
drop(engine);
|
|
|
|
let node_handle = state.node_handle.lock().await;
|
|
if let Some(ref handle) = *node_handle {
|
|
// subscribe to the community presence topic
|
|
let presence_topic = gossip::topic_for_presence(&invite.community_id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Subscribe {
|
|
topic: presence_topic,
|
|
})
|
|
.await;
|
|
|
|
// subscribe to all channel topics
|
|
for channel in &channels {
|
|
let msg_topic = gossip::topic_for_messages(&invite.community_id, &channel.id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Subscribe { topic: msg_topic })
|
|
.await;
|
|
|
|
let typing_topic = gossip::topic_for_typing(&invite.community_id, &channel.id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Subscribe {
|
|
topic: typing_topic,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
// register on rendezvous so existing members can find us
|
|
let namespace = format!("dusk/community/{}", invite.community_id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::RegisterRendezvous {
|
|
namespace: namespace.clone(),
|
|
})
|
|
.await;
|
|
|
|
// discover existing members through rendezvous
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::DiscoverRendezvous { namespace })
|
|
.await;
|
|
}
|
|
|
|
Ok(meta)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn leave_community(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
) -> Result<(), String> {
|
|
// unsubscribe from all community topics
|
|
let node_handle = state.node_handle.lock().await;
|
|
if let Some(ref handle) = *node_handle {
|
|
let engine = state.crdt_engine.lock().await;
|
|
if let Ok(channels) = engine.get_channels(&community_id) {
|
|
for channel in &channels {
|
|
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Unsubscribe { topic: msg_topic })
|
|
.await;
|
|
|
|
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Unsubscribe {
|
|
topic: typing_topic,
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
let presence_topic = gossip::topic_for_presence(&community_id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Unsubscribe {
|
|
topic: presence_topic,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_communities(state: State<'_, AppState>) -> Result<Vec<CommunityMeta>, String> {
|
|
let engine = state.crdt_engine.lock().await;
|
|
let mut communities = Vec::new();
|
|
|
|
for id in engine.community_ids() {
|
|
if let Ok(meta) = engine.get_community_meta(&id) {
|
|
communities.push(meta);
|
|
}
|
|
}
|
|
|
|
Ok(communities)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_channel(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
name: String,
|
|
topic: String,
|
|
kind: Option<String>,
|
|
category_id: Option<String>,
|
|
) -> Result<ChannelMeta, String> {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(community_id.as_bytes());
|
|
hasher.update(name.as_bytes());
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64;
|
|
hasher.update(now.to_le_bytes());
|
|
let hash = hasher.finalize();
|
|
let channel_id = format!("ch_{}", &hex::encode(hash)[..12]);
|
|
|
|
let channel_kind = match kind.as_deref() {
|
|
Some("voice") | Some("Voice") => ChannelKind::Voice,
|
|
_ => ChannelKind::Text,
|
|
};
|
|
|
|
let channel = ChannelMeta {
|
|
id: channel_id,
|
|
community_id: community_id.clone(),
|
|
name,
|
|
topic,
|
|
kind: channel_kind,
|
|
position: 0,
|
|
category_id,
|
|
};
|
|
|
|
let mut engine = state.crdt_engine.lock().await;
|
|
engine.create_channel(&community_id, &channel)?;
|
|
drop(engine);
|
|
|
|
// subscribe to the new channel's topics
|
|
let node_handle = state.node_handle.lock().await;
|
|
if let Some(ref handle) = *node_handle {
|
|
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Subscribe { topic: msg_topic })
|
|
.await;
|
|
|
|
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::Subscribe {
|
|
topic: typing_topic,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
Ok(channel)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_channels(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
) -> Result<Vec<ChannelMeta>, String> {
|
|
let engine = state.crdt_engine.lock().await;
|
|
engine.get_channels(&community_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_category(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
name: String,
|
|
) -> Result<CategoryMeta, String> {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(community_id.as_bytes());
|
|
hasher.update(name.as_bytes());
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64;
|
|
hasher.update(now.to_le_bytes());
|
|
let hash = hasher.finalize();
|
|
let category_id = format!("cat_{}", &hex::encode(hash)[..12]);
|
|
|
|
let category = CategoryMeta {
|
|
id: category_id,
|
|
community_id: community_id.clone(),
|
|
name,
|
|
position: 0,
|
|
};
|
|
|
|
let mut engine = state.crdt_engine.lock().await;
|
|
engine.create_category(&community_id, &category)?;
|
|
drop(engine);
|
|
|
|
// broadcast the change via document sync
|
|
let node_handle = state.node_handle.lock().await;
|
|
if let Some(ref handle) = *node_handle {
|
|
let sync_topic = "dusk/sync".to_string();
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::SendMessage {
|
|
topic: sync_topic,
|
|
data: community_id.as_bytes().to_vec(),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
Ok(category)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_categories(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
) -> Result<Vec<CategoryMeta>, String> {
|
|
let engine = state.crdt_engine.lock().await;
|
|
engine.get_categories(&community_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_members(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
) -> Result<Vec<Member>, String> {
|
|
let engine = state.crdt_engine.lock().await;
|
|
let mut members = engine.get_members(&community_id)?;
|
|
drop(engine);
|
|
|
|
// overlay the local user's identity so their display name stays current
|
|
let identity = state.identity.lock().await;
|
|
if let Some(ref id) = *identity {
|
|
let local_peer = id.peer_id.to_string();
|
|
let found = members.iter_mut().find(|m| m.peer_id == local_peer);
|
|
if let Some(member) = found {
|
|
member.display_name = id.display_name.clone();
|
|
member.status = PeerStatus::Online;
|
|
} else {
|
|
// local user isn't in the doc yet (shouldn't happen, but be safe)
|
|
members.push(Member {
|
|
peer_id: local_peer,
|
|
display_name: id.display_name.clone(),
|
|
status: PeerStatus::Online,
|
|
roles: vec!["member".to_string()],
|
|
trust_level: 1.0,
|
|
joined_at: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(members)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_message(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
message_id: String,
|
|
) -> Result<(), String> {
|
|
let identity = state.identity.lock().await;
|
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
|
let peer_id_str = id.peer_id.to_string();
|
|
drop(identity);
|
|
|
|
// verify the user is the message author or has admin rights
|
|
let mut engine = state.crdt_engine.lock().await;
|
|
let message = engine
|
|
.get_message(&community_id, &message_id)?
|
|
.ok_or_else(|| format!("message {} not found", message_id))?;
|
|
|
|
// only allow deletion by the author
|
|
if message.author_id != peer_id_str {
|
|
return Err("not authorized to delete this message".to_string());
|
|
}
|
|
|
|
engine.delete_message(&community_id, &message_id)?;
|
|
drop(engine);
|
|
|
|
// broadcast the deletion to peers
|
|
let node_handle = state.node_handle.lock().await;
|
|
if let Some(ref handle) = *node_handle {
|
|
// find the channel for this message to get the correct topic
|
|
let engine = state.crdt_engine.lock().await;
|
|
if let Ok(channels) = engine.get_channels(&community_id) {
|
|
for channel in &channels {
|
|
let topic = gossip::topic_for_messages(&community_id, &channel.id);
|
|
let deletion = crate::protocol::messages::GossipMessage::DeleteMessage {
|
|
message_id: message_id.clone(),
|
|
};
|
|
if let Ok(data) = serde_json::to_vec(&deletion) {
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::SendMessage { topic, data })
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn kick_member(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
member_peer_id: String,
|
|
) -> Result<(), String> {
|
|
let identity = state.identity.lock().await;
|
|
let id = identity.as_ref().ok_or("no identity loaded")?;
|
|
let requester_id = id.peer_id.to_string();
|
|
drop(identity);
|
|
|
|
// verify the requester has admin rights
|
|
let engine = state.crdt_engine.lock().await;
|
|
let members = engine.get_members(&community_id)?;
|
|
|
|
let requester = members
|
|
.iter()
|
|
.find(|m| m.peer_id == requester_id)
|
|
.ok_or("requester not found in community")?;
|
|
|
|
let is_admin = requester.roles.iter().any(|r| r == "admin" || r == "owner");
|
|
if !is_admin {
|
|
return Err("not authorized to kick members".to_string());
|
|
}
|
|
|
|
// cannot kick the owner
|
|
let target = members
|
|
.iter()
|
|
.find(|m| m.peer_id == member_peer_id)
|
|
.ok_or("member not found")?;
|
|
|
|
if target.roles.iter().any(|r| r == "owner") {
|
|
return Err("cannot kick the community owner".to_string());
|
|
}
|
|
|
|
drop(engine);
|
|
|
|
// remove the member from the community
|
|
let mut engine = state.crdt_engine.lock().await;
|
|
engine.remove_member(&community_id, &member_peer_id)?;
|
|
drop(engine);
|
|
|
|
// broadcast the kick to peers
|
|
let node_handle = state.node_handle.lock().await;
|
|
if let Some(ref handle) = *node_handle {
|
|
let presence_topic = gossip::topic_for_presence(&community_id);
|
|
let kick_msg = crate::protocol::messages::GossipMessage::MemberKicked {
|
|
peer_id: member_peer_id.clone(),
|
|
};
|
|
if let Ok(data) = serde_json::to_vec(&kick_msg) {
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::SendMessage {
|
|
topic: presence_topic,
|
|
data,
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn generate_invite(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
) -> Result<String, String> {
|
|
let engine = state.crdt_engine.lock().await;
|
|
let meta = engine.get_community_meta(&community_id)?;
|
|
drop(engine);
|
|
|
|
// invite contains only the community id and name
|
|
// no IP addresses or peer addresses are included
|
|
// peers discover each other through the relay's rendezvous protocol
|
|
let invite = crate::protocol::community::InviteCode {
|
|
community_id: meta.id.clone(),
|
|
community_name: meta.name.clone(),
|
|
};
|
|
|
|
Ok(invite.encode())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn reorder_channels(
|
|
state: State<'_, AppState>,
|
|
community_id: String,
|
|
channel_ids: Vec<String>,
|
|
) -> Result<Vec<ChannelMeta>, String> {
|
|
let mut engine = state.crdt_engine.lock().await;
|
|
let channels = engine.reorder_channels(&community_id, &channel_ids)?;
|
|
drop(engine);
|
|
|
|
// broadcast the reordering to peers via document sync
|
|
// the change will propagate through the existing gossipsub sync mechanism
|
|
let node_handle = state.node_handle.lock().await;
|
|
if let Some(ref handle) = *node_handle {
|
|
let sync_topic = "dusk/sync".to_string();
|
|
let _ = handle
|
|
.command_tx
|
|
.send(NodeCommand::SendMessage {
|
|
topic: sync_topic,
|
|
data: community_id.as_bytes().to_vec(),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
Ok(channels)
|
|
}
|