started implementing core features (profiles, dms, servers, text/voice, settings, etc)

added icons and branding
This commit is contained in:
cloudwithax 2026-02-14 19:54:55 -05:00
parent e008d4e579
commit 1f06ada9d2
103 changed files with 6797 additions and 546 deletions

View File

@ -9,6 +9,7 @@
"@fontsource/space-grotesk": "^5.2.0", "@fontsource/space-grotesk": "^5.2.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@thisbeyond/solid-dnd": "^0.7.5",
"lucide-solid": "^0.469.0", "lucide-solid": "^0.469.0",
"motion": "^12.0.0", "motion": "^12.0.0",
"solid-js": "^1.9.3", "solid-js": "^1.9.3",
@ -255,6 +256,8 @@
"@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="],
"@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],

View File

@ -15,6 +15,7 @@
"@fontsource/space-grotesk": "^5.2.0", "@fontsource/space-grotesk": "^5.2.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
"@thisbeyond/solid-dnd": "^0.7.5",
"lucide-solid": "^0.469.0", "lucide-solid": "^0.469.0",
"motion": "^12.0.0", "motion": "^12.0.0",
"solid-js": "^1.9.3", "solid-js": "^1.9.3",

1
src-tauri/Cargo.lock generated
View File

@ -1123,6 +1123,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-shell", "tauri-plugin-shell",
"tokio", "tokio",
"webkit2gtk",
] ]
[[package]] [[package]]

View File

@ -29,6 +29,7 @@ libp2p = { version = "0.54", features = [
"noise", "noise",
"yamux", "yamux",
"tcp", "tcp",
"dns",
"tokio", "tokio",
"identify", "identify",
"macros", "macros",
@ -56,3 +57,7 @@ dotenvy = "0.15"
# async utilities # async utilities
futures = "0.3" futures = "0.3"
# platform-specific: webview media permissions on linux
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 417 B

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 666 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,10 +1,13 @@
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::State; use tauri::State;
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
use crate::node::gossip; use crate::node::gossip;
use crate::node::{self, NodeCommand}; use crate::node::{self, NodeCommand};
use crate::protocol::messages::{ChatMessage, GossipMessage, ProfileAnnouncement, TypingIndicator}; use crate::protocol::messages::{ChatMessage, GossipMessage, ProfileAnnouncement, TypingIndicator};
use crate::verification;
use crate::AppState; use crate::AppState;
#[tauri::command] #[tauri::command]
@ -19,11 +22,12 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re
state.crdt_engine.clone(), state.crdt_engine.clone(),
state.storage.clone(), state.storage.clone(),
app, app,
state.voice_channels.clone(),
) )
.await?; .await?;
// capture profile info for announcement before dropping identity lock // capture profile info for announcement before dropping identity lock
let profile_announcement = ProfileAnnouncement { let mut profile_announcement = ProfileAnnouncement {
peer_id: id.peer_id.to_string(), peer_id: id.peer_id.to_string(),
display_name: id.display_name.clone(), display_name: id.display_name.clone(),
bio: id.bio.clone(), bio: id.bio.clone(),
@ -32,7 +36,11 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_millis() as u64, .as_millis() as u64,
verification_proof: id.verification_proof.clone(),
signature: String::new(),
}; };
profile_announcement.signature =
verification::sign_announcement(&id.keypair, &profile_announcement);
drop(identity); drop(identity);
{ {
@ -114,6 +122,41 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re
.send(NodeCommand::RegisterRendezvous { namespace }) .send(NodeCommand::RegisterRendezvous { namespace })
.await; .await;
} }
// subscribe to all existing dm conversation topics
let local_peer_str = {
let identity = state.identity.lock().await;
identity
.as_ref()
.map(|i| i.peer_id.to_string())
.unwrap_or_default()
};
if let Ok(conversations) = state.storage.load_all_dm_conversations() {
for (_, meta) in &conversations {
let dm_topic = gossip::topic_for_dm(&local_peer_str, &meta.peer_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic: dm_topic })
.await;
}
}
// subscribe to personal dm inbox so first-time dms from any peer land
let inbox_topic = gossip::topic_for_dm_inbox(&local_peer_str);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic: inbox_topic })
.await;
// register personal rendezvous namespace so any peer can discover
// and connect to us for dms even without sharing a community
let personal_ns = format!("dusk/peer/{}", local_peer_str);
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous {
namespace: personal_ns,
})
.await;
} }
Ok(()) Ok(())
@ -242,3 +285,28 @@ fn find_community_for_channel(
channel_id channel_id
)) ))
} }
// attempts tcp connections to well-known hosts to distinguish
// between a general internet outage and the relay being unreachable
#[tauri::command]
pub async fn check_internet_connectivity() -> Result<bool, String> {
let hosts = vec![
("www.apple.com", 80),
("www.google.com", 80),
("www.yahoo.com", 80),
];
let connect_timeout = Duration::from_secs(5);
let futures: Vec<_> = hosts
.into_iter()
.map(|(host, port)| {
let addr = format!("{}:{}", host, port);
timeout(connect_timeout, TcpStream::connect(addr))
})
.collect();
let results = futures::future::join_all(futures).await;
Ok(results.iter().any(|r| matches!(r, Ok(Ok(_)))))
}

View File

@ -5,7 +5,7 @@ use tauri::State;
use crate::node::gossip; use crate::node::gossip;
use crate::node::NodeCommand; use crate::node::NodeCommand;
use crate::protocol::community::{ChannelKind, ChannelMeta, CommunityMeta, Member}; use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, CommunityMeta, Member};
use crate::protocol::messages::PeerStatus; use crate::protocol::messages::PeerStatus;
use crate::AppState; use crate::AppState;
@ -224,6 +224,8 @@ pub async fn create_channel(
community_id: String, community_id: String,
name: String, name: String,
topic: String, topic: String,
kind: Option<String>,
category_id: Option<String>,
) -> Result<ChannelMeta, String> { ) -> Result<ChannelMeta, String> {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(community_id.as_bytes()); hasher.update(community_id.as_bytes());
@ -236,12 +238,19 @@ pub async fn create_channel(
let hash = hasher.finalize(); let hash = hasher.finalize();
let channel_id = format!("ch_{}", &hex::encode(hash)[..12]); 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 { let channel = ChannelMeta {
id: channel_id, id: channel_id,
community_id: community_id.clone(), community_id: community_id.clone(),
name, name,
topic, topic,
kind: ChannelKind::Text, kind: channel_kind,
position: 0,
category_id,
}; };
let mut engine = state.crdt_engine.lock().await; let mut engine = state.crdt_engine.lock().await;
@ -278,6 +287,59 @@ pub async fn get_channels(
engine.get_channels(&community_id) 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] #[tauri::command]
pub async fn get_members( pub async fn get_members(
state: State<'_, AppState>, state: State<'_, AppState>,
@ -442,3 +504,30 @@ pub async fn generate_invite(
Ok(invite.encode()) 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)
}

View File

@ -0,0 +1,312 @@
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::State;
use crate::node::gossip;
use crate::node::NodeCommand;
use crate::protocol::messages::{
DMConversationMeta, DMTypingIndicator, DirectMessage, GossipMessage,
};
use crate::AppState;
// send a direct message to a peer
// creates the conversation on disk if it doesn't exist,
// publishes the message over gossipsub on the pair topic
#[tauri::command]
pub async fn send_dm(
state: State<'_, AppState>,
peer_id: String,
content: String,
) -> Result<DirectMessage, 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;
let local_peer_id = id.peer_id.to_string();
let display_name = id.display_name.clone();
drop(identity);
let msg = DirectMessage {
id: format!("dm_{}_{}", local_peer_id, now),
from_peer: local_peer_id.clone(),
to_peer: peer_id.clone(),
from_display_name: display_name.clone(),
content: content.clone(),
timestamp: now,
};
// derive the conversation id and persist the message
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
state
.storage
.append_dm_message(&conversation_id, &msg)
.map_err(|e| format!("failed to persist dm: {}", e))?;
// ensure conversation metadata exists on disk
// try to load existing meta to preserve peer's display name,
// fall back to what we know from the directory
let existing_meta = state.storage.load_dm_conversation(&conversation_id).ok();
let peer_display_name = existing_meta
.as_ref()
.map(|m| m.display_name.clone())
.unwrap_or_else(|| {
// look up in directory
state
.storage
.load_directory()
.ok()
.and_then(|d| d.get(&peer_id).map(|e| e.display_name.clone()))
.unwrap_or_else(|| peer_id.clone())
});
let meta = DMConversationMeta {
peer_id: peer_id.clone(),
display_name: peer_display_name,
last_message: Some(content),
last_message_time: Some(now),
unread_count: existing_meta.map(|m| m.unread_count).unwrap_or(0),
};
state
.storage
.save_dm_conversation(&conversation_id, &meta)
.map_err(|e| format!("failed to save dm conversation: {}", e))?;
// publish to the dm gossipsub topic
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let data = serde_json::to_vec(&GossipMessage::DirectMessage(msg.clone()))
.map_err(|e| format!("serialize error: {}", e))?;
// publish to the pair topic (for when both peers are already subscribed)
let pair_topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: pair_topic,
data: data.clone(),
})
.await;
// also publish to the recipient's inbox topic to guarantee delivery
// on first-time dms where the peer isn't subscribed to the pair topic yet
let inbox_topic = gossip::topic_for_dm_inbox(&peer_id);
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: inbox_topic,
data,
})
.await;
// discover the peer via rendezvous in case we're not connected over wan
let discover_ns = format!("dusk/peer/{}", peer_id);
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous {
namespace: discover_ns,
})
.await;
}
Ok(msg)
}
// load dm messages for a conversation with a specific peer
#[tauri::command]
pub async fn get_dm_messages(
state: State<'_, AppState>,
peer_id: String,
before: Option<u64>,
limit: Option<usize>,
) -> Result<Vec<DirectMessage>, String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
state
.storage
.load_dm_messages(&conversation_id, before, limit.unwrap_or(50))
.map_err(|e| format!("failed to load dm messages: {}", e))
}
// load all dm conversations for the sidebar
#[tauri::command]
pub async fn get_dm_conversations(
state: State<'_, AppState>,
) -> Result<Vec<DMConversationMeta>, String> {
let conversations = state
.storage
.load_all_dm_conversations()
.map_err(|e| format!("failed to load dm conversations: {}", e))?;
Ok(conversations.into_iter().map(|(_, meta)| meta).collect())
}
// mark all messages in a dm conversation as read
#[tauri::command]
pub async fn mark_dm_read(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
let mut meta = state
.storage
.load_dm_conversation(&conversation_id)
.map_err(|e| format!("failed to load conversation: {}", e))?;
meta.unread_count = 0;
state
.storage
.save_dm_conversation(&conversation_id, &meta)
.map_err(|e| format!("failed to save conversation: {}", e))
}
// delete a dm conversation and all its messages
#[tauri::command]
pub async fn delete_dm_conversation(
state: State<'_, AppState>,
peer_id: String,
) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
// unsubscribe from the dm topic
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe { topic })
.await;
}
state
.storage
.remove_dm_conversation(&conversation_id)
.map_err(|e| format!("failed to delete conversation: {}", e))
}
// send a typing indicator in a dm conversation
#[tauri::command]
pub async fn send_dm_typing(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let local_peer_id = id.peer_id.to_string();
drop(identity);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let indicator = DMTypingIndicator {
from_peer: local_peer_id.clone(),
to_peer: peer_id.clone(),
timestamp: now,
};
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let data = serde_json::to_vec(&GossipMessage::DMTyping(indicator))
.map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage { topic, data })
.await;
}
Ok(())
}
// open a dm conversation with a peer (creates metadata on disk and subscribes to topic)
// used when clicking "message" on a peer's profile
#[tauri::command]
pub async fn open_dm_conversation(
state: State<'_, AppState>,
peer_id: String,
display_name: String,
) -> Result<DMConversationMeta, String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
// check if conversation already exists
if let Ok(existing) = state.storage.load_dm_conversation(&conversation_id) {
// subscribe to make sure we're listening
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic })
.await;
// discover the peer via rendezvous to ensure wan connectivity
let discover_ns = format!("dusk/peer/{}", peer_id);
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous {
namespace: discover_ns,
})
.await;
}
return Ok(existing);
}
let meta = DMConversationMeta {
peer_id: peer_id.clone(),
display_name,
last_message: None,
last_message_time: None,
unread_count: 0,
};
state
.storage
.save_dm_conversation(&conversation_id, &meta)
.map_err(|e| format!("failed to create dm conversation: {}", e))?;
// subscribe to the dm topic so we receive messages
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic })
.await;
// discover the peer via rendezvous to establish wan connectivity
// through the relay circuit before any messages are sent
let discover_ns = format!("dusk/peer/{}", peer_id);
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous {
namespace: discover_ns,
})
.await;
}
Ok(meta)
}

View File

@ -5,10 +5,44 @@ use tauri::State;
use crate::node::gossip; use crate::node::gossip;
use crate::node::NodeCommand; use crate::node::NodeCommand;
use crate::protocol::identity::{DirectoryEntry, DuskIdentity, PublicIdentity}; use crate::protocol::identity::{DirectoryEntry, DuskIdentity, PublicIdentity};
use crate::protocol::messages::{GossipMessage, ProfileRevocation}; use crate::protocol::messages::{GossipMessage, ProfileAnnouncement, ProfileRevocation};
use crate::storage::UserSettings; use crate::storage::UserSettings;
use crate::verification::{self, ChallengeSubmission};
use crate::AppState; use crate::AppState;
// build a signed profile announcement and publish it on the directory topic
// so all connected peers immediately learn about the updated profile.
// silently no-ops if the node isn't running yet.
async fn announce_profile(id: &DuskIdentity, state: &AppState) {
let mut announcement = ProfileAnnouncement {
peer_id: id.peer_id.to_string(),
display_name: id.display_name.clone(),
bio: id.bio.clone(),
public_key: hex::encode(id.keypair.public().encode_protobuf()),
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64,
verification_proof: id.verification_proof.clone(),
signature: String::new(),
};
announcement.signature = verification::sign_announcement(&id.keypair, &announcement);
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let msg = GossipMessage::ProfileAnnounce(announcement);
if let Ok(data) = serde_json::to_vec(&msg) {
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: gossip::topic_for_directory(),
data,
})
.await;
}
}
}
#[tauri::command] #[tauri::command]
pub async fn has_identity(state: State<'_, AppState>) -> Result<bool, String> { pub async fn has_identity(state: State<'_, AppState>) -> Result<bool, String> {
Ok(state.storage.has_identity()) Ok(state.storage.has_identity())
@ -37,8 +71,30 @@ pub async fn create_identity(
state: State<'_, AppState>, state: State<'_, AppState>,
display_name: String, display_name: String,
bio: Option<String>, bio: Option<String>,
challenge_data: Option<ChallengeSubmission>,
) -> Result<PublicIdentity, String> { ) -> Result<PublicIdentity, String> {
let new_identity = DuskIdentity::generate(&display_name, &bio.unwrap_or_default()); // require challenge data and re-validate behavioral analysis in rust
let challenge = challenge_data.ok_or("verification required")?;
let result = verification::analyze_challenge(&challenge);
if !result.is_human {
return Err("verification failed".to_string());
}
let mut new_identity = DuskIdentity::generate(&display_name, &bio.unwrap_or_default());
// generate a cryptographic proof binding the verification to this keypair
let proof = verification::generate_proof(
&challenge,
&new_identity.keypair,
&new_identity.peer_id.to_string(),
)?;
state
.storage
.save_verification_proof(&proof)
.map_err(|e| format!("failed to save verification proof: {}", e))?;
new_identity.verification_proof = Some(proof);
new_identity.save(&state.storage)?; new_identity.save(&state.storage)?;
// also save initial settings with this display name so they're in sync // also save initial settings with this display name so they're in sync
@ -64,6 +120,8 @@ pub async fn update_display_name(state: State<'_, AppState>, name: String) -> Re
id.display_name = name; id.display_name = name;
id.save(&state.storage)?; id.save(&state.storage)?;
announce_profile(id, &state).await;
Ok(()) Ok(())
} }
@ -80,7 +138,12 @@ pub async fn update_profile(
id.bio = bio; id.bio = bio;
id.save(&state.storage)?; id.save(&state.storage)?;
Ok(id.public_identity()) let public = id.public_identity();
// re-announce so connected peers see the change immediately
announce_profile(id, &state).await;
Ok(public)
} }
#[tauri::command] #[tauri::command]
@ -98,13 +161,23 @@ pub async fn save_settings(
) -> Result<(), String> { ) -> Result<(), String> {
// also update the identity display name if it changed // also update the identity display name if it changed
let mut identity = state.identity.lock().await; let mut identity = state.identity.lock().await;
let mut name_changed = false;
if let Some(id) = identity.as_mut() { if let Some(id) = identity.as_mut() {
if id.display_name != settings.display_name { if id.display_name != settings.display_name {
id.display_name = settings.display_name.clone(); id.display_name = settings.display_name.clone();
id.save(&state.storage)?; id.save(&state.storage)?;
name_changed = true;
} }
} }
// re-announce if the display name was updated through settings
if name_changed {
if let Some(id) = identity.as_ref() {
announce_profile(id, &state).await;
}
}
drop(identity);
state state
.storage .storage
.save_settings(&settings) .save_settings(&settings)
@ -192,14 +265,16 @@ pub async fn reset_identity(state: State<'_, AppState>) -> Result<(), String> {
let id = identity.as_ref().ok_or("no identity loaded")?; let id = identity.as_ref().ok_or("no identity loaded")?;
// build the revocation message before we destroy the identity // build the revocation message before we destroy the identity
let revocation = ProfileRevocation { let mut revocation = ProfileRevocation {
peer_id: id.peer_id.to_string(), peer_id: id.peer_id.to_string(),
public_key: hex::encode(id.keypair.public().encode_protobuf()), public_key: hex::encode(id.keypair.public().encode_protobuf()),
timestamp: SystemTime::now() timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_millis() as u64, .as_millis() as u64,
signature: String::new(),
}; };
revocation.signature = verification::sign_revocation(&id.keypair, &revocation);
// broadcast revocation on the directory gossip topic // broadcast revocation on the directory gossip topic
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;

View File

@ -1,3 +1,5 @@
pub mod chat; pub mod chat;
pub mod community; pub mod community;
pub mod dm;
pub mod identity; pub mod identity;
pub mod voice;

View File

@ -0,0 +1,268 @@
use tauri::State;
use crate::node::gossip;
use crate::node::NodeCommand;
use crate::protocol::messages::{GossipMessage, VoiceMediaState, VoiceParticipant};
use crate::AppState;
#[tauri::command]
pub async fn join_voice_channel(
state: State<'_, AppState>,
community_id: String,
channel_id: String,
) -> Result<Vec<VoiceParticipant>, String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let peer_id = id.peer_id.to_string();
let display_name = id.display_name.clone();
drop(identity);
let media_state = VoiceMediaState {
muted: false,
deafened: false,
video_enabled: false,
screen_sharing: false,
};
// subscribe to the voice topic for this channel
let voice_topic = gossip::topic_for_voice(&community_id, &channel_id);
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: voice_topic.clone(),
})
.await;
// publish our join announcement
let msg = GossipMessage::VoiceJoin {
community_id: community_id.clone(),
channel_id: channel_id.clone(),
peer_id: peer_id.clone(),
display_name: display_name.clone(),
media_state: media_state.clone(),
};
let data =
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: voice_topic,
data,
})
.await;
}
// add ourselves to the local voice channel tracking
let key = format!("{}:{}", community_id, channel_id);
let mut vc = state.voice_channels.lock().await;
let participants = vc.entry(key.clone()).or_insert_with(Vec::new);
participants.retain(|p| p.peer_id != peer_id);
participants.push(VoiceParticipant {
peer_id,
display_name,
media_state,
});
let result = participants.clone();
drop(vc);
log::info!("joined voice channel {}:{}", community_id, channel_id);
Ok(result)
}
#[tauri::command]
pub async fn leave_voice_channel(
state: State<'_, AppState>,
community_id: String,
channel_id: String,
) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let peer_id = id.peer_id.to_string();
drop(identity);
let voice_topic = gossip::topic_for_voice(&community_id, &channel_id);
// publish our leave announcement before unsubscribing
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let msg = GossipMessage::VoiceLeave {
community_id: community_id.clone(),
channel_id: channel_id.clone(),
peer_id: peer_id.clone(),
};
let data =
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: voice_topic.clone(),
data,
})
.await;
// unsubscribe from the voice topic
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe {
topic: voice_topic,
})
.await;
}
// remove ourselves from local tracking
let key = format!("{}:{}", community_id, channel_id);
let mut vc = state.voice_channels.lock().await;
if let Some(participants) = vc.get_mut(&key) {
participants.retain(|p| p.peer_id != peer_id);
if participants.is_empty() {
vc.remove(&key);
}
}
drop(vc);
log::info!("left voice channel {}:{}", community_id, channel_id);
Ok(())
}
#[tauri::command]
pub async fn update_voice_media_state(
state: State<'_, AppState>,
community_id: String,
channel_id: String,
media_state: VoiceMediaState,
) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let peer_id = id.peer_id.to_string();
drop(identity);
let voice_topic = gossip::topic_for_voice(&community_id, &channel_id);
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let msg = GossipMessage::VoiceMediaStateUpdate {
community_id: community_id.clone(),
channel_id: channel_id.clone(),
peer_id: peer_id.clone(),
media_state: media_state.clone(),
};
let data =
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: voice_topic,
data,
})
.await;
}
// update local tracking
let key = format!("{}:{}", community_id, channel_id);
let mut vc = state.voice_channels.lock().await;
if let Some(participants) = vc.get_mut(&key) {
if let Some(p) = participants.iter_mut().find(|p| p.peer_id == peer_id) {
p.media_state = media_state;
}
}
drop(vc);
Ok(())
}
#[tauri::command]
pub async fn send_voice_sdp(
state: State<'_, AppState>,
community_id: String,
channel_id: String,
to_peer: String,
sdp_type: String,
sdp: String,
) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let from_peer = id.peer_id.to_string();
drop(identity);
let voice_topic = gossip::topic_for_voice(&community_id, &channel_id);
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let msg = GossipMessage::VoiceSdp {
community_id,
channel_id,
from_peer,
to_peer,
sdp_type,
sdp,
};
let data =
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: voice_topic,
data,
})
.await;
}
Ok(())
}
#[tauri::command]
pub async fn send_voice_ice_candidate(
state: State<'_, AppState>,
community_id: String,
channel_id: String,
to_peer: String,
candidate: String,
sdp_mid: Option<String>,
sdp_mline_index: Option<u32>,
) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let from_peer = id.peer_id.to_string();
drop(identity);
let voice_topic = gossip::topic_for_voice(&community_id, &channel_id);
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let msg = GossipMessage::VoiceIceCandidate {
community_id,
channel_id,
from_peer,
to_peer,
candidate,
sdp_mid,
sdp_mline_index,
};
let data =
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: voice_topic,
data,
})
.await;
}
Ok(())
}
#[tauri::command]
pub async fn get_voice_participants(
state: State<'_, AppState>,
community_id: String,
channel_id: String,
) -> Result<Vec<VoiceParticipant>, String> {
let key = format!("{}:{}", community_id, channel_id);
let vc = state.voice_channels.lock().await;
let participants = vc.get(&key).cloned().unwrap_or_default();
Ok(participants)
}

View File

@ -1,7 +1,7 @@
use automerge::{transaction::Transactable, AutoCommit, ObjType, ReadDoc, ROOT}; use automerge::{transaction::Transactable, AutoCommit, ObjType, ReadDoc, ROOT};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::protocol::community::{ChannelKind, ChannelMeta, CommunityMeta}; use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, CommunityMeta};
use crate::protocol::messages::ChatMessage; use crate::protocol::messages::ChatMessage;
// initialize a new community document with metadata and a default general channel // initialize a new community document with metadata and a default general channel
@ -24,15 +24,20 @@ pub fn init_community_doc(
doc.put(&meta, "created_at", now as i64)?; doc.put(&meta, "created_at", now as i64)?;
let channels = doc.put_object(ROOT, "channels", ObjType::Map)?; let channels = doc.put_object(ROOT, "channels", ObjType::Map)?;
let _categories = doc.put_object(ROOT, "categories", ObjType::Map)?;
let members = doc.put_object(ROOT, "members", ObjType::Map)?; let members = doc.put_object(ROOT, "members", ObjType::Map)?;
let _roles = doc.put_object(ROOT, "roles", ObjType::Map)?; let _roles = doc.put_object(ROOT, "roles", ObjType::Map)?;
// create a default general channel // create a default general channel
let general_id = format!("ch_{}", &hex::encode(&sha2_hash(format!("{}_general", name).as_bytes()))[..12]); let general_id = format!(
"ch_{}",
&hex::encode(&sha2_hash(format!("{}_general", name).as_bytes()))[..12]
);
let general = doc.put_object(&channels, &general_id, ObjType::Map)?; let general = doc.put_object(&channels, &general_id, ObjType::Map)?;
doc.put(&general, "name", "general")?; doc.put(&general, "name", "general")?;
doc.put(&general, "topic", "general discussion")?; doc.put(&general, "topic", "general discussion")?;
doc.put(&general, "kind", "text")?; doc.put(&general, "kind", "text")?;
doc.put(&general, "position", 0i64)?;
let _messages = doc.put_object(&general, "messages", ObjType::List)?; let _messages = doc.put_object(&general, "messages", ObjType::List)?;
// add the creator as the first member with owner role // add the creator as the first member with owner role
@ -55,6 +60,23 @@ pub fn add_channel(
.map(|(_, id)| id) .map(|(_, id)| id)
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?; .ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?;
// calculate next position if channel.position is 0
let position = if channel.position == 0 {
let keys: Vec<String> = doc.keys(&channels).collect();
keys.iter()
.filter_map(|k| {
doc.get(&channels, k)
.ok()
.flatten()
.and_then(|(_, id)| get_i64(doc, &id, "position"))
})
.max()
.map(|p| p + 1)
.unwrap_or(0) as u32
} else {
channel.position
};
let ch = doc.put_object(&channels, &channel.id, ObjType::Map)?; let ch = doc.put_object(&channels, &channel.id, ObjType::Map)?;
doc.put(&ch, "name", channel.name.as_str())?; doc.put(&ch, "name", channel.name.as_str())?;
doc.put(&ch, "topic", channel.topic.as_str())?; doc.put(&ch, "topic", channel.topic.as_str())?;
@ -66,11 +88,93 @@ pub fn add_channel(
ChannelKind::Voice => "voice", ChannelKind::Voice => "voice",
}, },
)?; )?;
doc.put(&ch, "position", position as i64)?;
if let Some(ref cat_id) = channel.category_id {
doc.put(&ch, "category_id", cat_id.as_str())?;
}
let _messages = doc.put_object(&ch, "messages", ObjType::List)?; let _messages = doc.put_object(&ch, "messages", ObjType::List)?;
Ok(()) Ok(())
} }
// add a new category to the community document
pub fn add_category(
doc: &mut AutoCommit,
category: &CategoryMeta,
) -> Result<(), automerge::AutomergeError> {
let categories = doc
.get(ROOT, "categories")?
.map(|(_, id)| id)
.ok_or_else(|| {
// backwards compat: create categories map if it doesnt exist yet
automerge::AutomergeError::InvalidObjId("categories not found".to_string())
});
let categories = match categories {
Ok(id) => id,
Err(_) => doc.put_object(ROOT, "categories", ObjType::Map)?,
};
// calculate next position if category.position is 0
let position = if category.position == 0 {
let keys: Vec<String> = doc.keys(&categories).collect();
keys.iter()
.filter_map(|k| {
doc.get(&categories, k)
.ok()
.flatten()
.and_then(|(_, id)| get_i64(doc, &id, "position"))
})
.max()
.map(|p| p + 1)
.unwrap_or(0) as u32
} else {
category.position
};
let cat = doc.put_object(&categories, &category.id, ObjType::Map)?;
doc.put(&cat, "name", category.name.as_str())?;
doc.put(&cat, "position", position as i64)?;
Ok(())
}
// read all categories from the community document
pub fn get_categories(doc: &AutoCommit, community_id: &str) -> Result<Vec<CategoryMeta>, String> {
let categories_obj = doc.get(ROOT, "categories").map_err(|e| e.to_string())?;
// backwards compat: older docs may not have categories
let categories_obj = match categories_obj {
Some((_, id)) => id,
None => return Ok(Vec::new()),
};
let mut result = Vec::new();
let keys = doc.keys(&categories_obj);
for key in keys {
let cat_obj = doc
.get(&categories_obj, &key)
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(cat_id) = cat_obj {
let name = get_str(doc, &cat_id, "name").unwrap_or_default();
let position = get_i64(doc, &cat_id, "position").unwrap_or(0) as u32;
result.push(CategoryMeta {
id: key.to_string(),
community_id: community_id.to_string(),
name,
position,
});
}
}
result.sort_by_key(|c| c.position);
Ok(result)
}
// read all channels from the community document // read all channels from the community document
pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelMeta>, String> { pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelMeta>, String> {
let channels_obj = doc let channels_obj = doc
@ -96,6 +200,8 @@ pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelM
"voice" => ChannelKind::Voice, "voice" => ChannelKind::Voice,
_ => ChannelKind::Text, _ => ChannelKind::Text,
}; };
let position = get_i64(doc, &ch_id, "position").unwrap_or(0) as u32;
let category_id = get_str(doc, &ch_id, "category_id");
result.push(ChannelMeta { result.push(ChannelMeta {
id: key.to_string(), id: key.to_string(),
@ -103,10 +209,15 @@ pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelM
name, name,
topic, topic,
kind, kind,
position,
category_id,
}); });
} }
} }
// sort by position
result.sort_by_key(|c| c.position);
Ok(result) Ok(result)
} }
@ -227,6 +338,33 @@ pub fn get_community_meta(doc: &AutoCommit, community_id: &str) -> Result<Commun
}) })
} }
// reorder channels by updating their positions
pub fn reorder_channels(
doc: &mut AutoCommit,
community_id: &str,
channel_ids: &[String],
) -> Result<Vec<ChannelMeta>, String> {
let channels_obj = doc
.get(ROOT, "channels")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("channels key not found")?;
// update position for each channel
for (index, channel_id) in channel_ids.iter().enumerate() {
if let Some((_, ch_obj)) = doc
.get(&channels_obj, channel_id)
.map_err(|e| e.to_string())?
{
doc.put(&ch_obj, "position", index as i64)
.map_err(|e| e.to_string())?;
}
}
// return updated channels sorted by position
get_channels(doc, community_id)
}
// -- helpers for reading automerge values -- // -- helpers for reading automerge values --
fn get_str(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option<String> { fn get_str(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option<String> {
@ -270,7 +408,7 @@ pub fn get_message_by_id(
.ok_or("channels key not found")?; .ok_or("channels key not found")?;
let keys = doc.keys(&channels_obj); let keys = doc.keys(&channels_obj);
for channel_key in keys { for channel_key in keys {
let ch_obj = doc let ch_obj = doc
.get(&channels_obj, &channel_key) .get(&channels_obj, &channel_key)
@ -298,7 +436,8 @@ pub fn get_message_by_id(
id: id.clone(), id: id.clone(),
channel_id: channel_key.to_string(), channel_id: channel_key.to_string(),
author_id: get_str(doc, &msg_id, "author_id").unwrap_or_default(), author_id: get_str(doc, &msg_id, "author_id").unwrap_or_default(),
author_name: get_str(doc, &msg_id, "author_name").unwrap_or_default(), author_name: get_str(doc, &msg_id, "author_name")
.unwrap_or_default(),
content: get_str(doc, &msg_id, "content").unwrap_or_default(), content: get_str(doc, &msg_id, "content").unwrap_or_default(),
timestamp: get_i64(doc, &msg_id, "timestamp").unwrap_or(0) as u64, timestamp: get_i64(doc, &msg_id, "timestamp").unwrap_or(0) as u64,
edited: get_bool(doc, &msg_id, "edited").unwrap_or(false), edited: get_bool(doc, &msg_id, "edited").unwrap_or(false),
@ -315,10 +454,7 @@ pub fn get_message_by_id(
} }
// delete a message by id from any channel in the community // delete a message by id from any channel in the community
pub fn delete_message_by_id( pub fn delete_message_by_id(doc: &mut AutoCommit, message_id: &str) -> Result<(), String> {
doc: &mut AutoCommit,
message_id: &str,
) -> Result<(), String> {
let channels_obj = doc let channels_obj = doc
.get(ROOT, "channels") .get(ROOT, "channels")
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@ -326,7 +462,7 @@ pub fn delete_message_by_id(
.ok_or("channels key not found")?; .ok_or("channels key not found")?;
let keys: Vec<String> = doc.keys(&channels_obj).collect(); let keys: Vec<String> = doc.keys(&channels_obj).collect();
for channel_key in keys { for channel_key in keys {
let ch_obj = doc let ch_obj = doc
.get(&channels_obj, &channel_key) .get(&channels_obj, &channel_key)
@ -350,8 +486,7 @@ pub fn delete_message_by_id(
if let Some(msg_obj_id) = msg_obj { if let Some(msg_obj_id) = msg_obj {
let id = get_str(doc, &msg_obj_id, "id").unwrap_or_default(); let id = get_str(doc, &msg_obj_id, "id").unwrap_or_default();
if id == message_id { if id == message_id {
doc.delete(&msgs_id, i) doc.delete(&msgs_id, i).map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
return Ok(()); return Ok(());
} }
} }
@ -364,9 +499,7 @@ pub fn delete_message_by_id(
} }
// get all members from the community document // get all members from the community document
pub fn get_members( pub fn get_members(doc: &AutoCommit) -> Result<Vec<crate::protocol::community::Member>, String> {
doc: &AutoCommit,
) -> Result<Vec<crate::protocol::community::Member>, String> {
let members_obj = doc let members_obj = doc
.get(ROOT, "members") .get(ROOT, "members")
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@ -385,7 +518,7 @@ pub fn get_members(
if let Some(member_id) = member_obj { if let Some(member_id) = member_obj {
let display_name = get_str(doc, &member_id, "display_name").unwrap_or_default(); let display_name = get_str(doc, &member_id, "display_name").unwrap_or_default();
let joined_at = get_i64(doc, &member_id, "joined_at").unwrap_or(0) as u64; let joined_at = get_i64(doc, &member_id, "joined_at").unwrap_or(0) as u64;
// get roles list // get roles list
let roles: Vec<String> = doc let roles: Vec<String> = doc
.get(&member_id, "roles") .get(&member_id, "roles")
@ -419,10 +552,7 @@ pub fn get_members(
} }
// remove a member from the community // remove a member from the community
pub fn remove_member( pub fn remove_member(doc: &mut AutoCommit, peer_id: &str) -> Result<(), String> {
doc: &mut AutoCommit,
peer_id: &str,
) -> Result<(), String> {
let members_obj = doc let members_obj = doc
.get(ROOT, "members") .get(ROOT, "members")
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?

View File

@ -6,7 +6,7 @@ use std::sync::Arc;
use automerge::AutoCommit; use automerge::AutoCommit;
use crate::protocol::community::{ChannelMeta, CommunityMeta}; use crate::protocol::community::{CategoryMeta, ChannelMeta, CommunityMeta};
use crate::protocol::messages::ChatMessage; use crate::protocol::messages::ChatMessage;
use crate::storage::DiskStorage; use crate::storage::DiskStorage;
@ -91,6 +91,50 @@ impl CrdtEngine {
document::get_channels(doc, community_id) document::get_channels(doc, community_id)
} }
// reorder channels in a community
pub fn reorder_channels(
&mut self,
community_id: &str,
channel_ids: &[String],
) -> Result<Vec<ChannelMeta>, String> {
let doc = self
.documents
.get_mut(community_id)
.ok_or("community not found")?;
let channels = document::reorder_channels(doc, community_id, channel_ids)?;
self.persist(community_id)?;
Ok(channels)
}
// add a category to a community
pub fn create_category(
&mut self,
community_id: &str,
category: &CategoryMeta,
) -> Result<(), String> {
let doc = self
.documents
.get_mut(community_id)
.ok_or("community not found")?;
document::add_category(doc, category)
.map_err(|e| format!("failed to add category: {}", e))?;
self.persist(community_id)?;
Ok(())
}
// get all categories in a community
pub fn get_categories(&self, community_id: &str) -> Result<Vec<CategoryMeta>, String> {
let doc = self
.documents
.get(community_id)
.ok_or("community not found")?;
document::get_categories(doc, community_id)
}
// append a message to a channel within a community // append a message to a channel within a community
pub fn append_message( pub fn append_message(
&mut self, &mut self,

View File

@ -3,12 +3,15 @@ mod crdt;
mod node; mod node;
mod protocol; mod protocol;
mod storage; mod storage;
mod verification;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::crdt::CrdtEngine; use crate::crdt::CrdtEngine;
use crate::protocol::identity::DuskIdentity; use crate::protocol::identity::DuskIdentity;
use crate::protocol::messages::VoiceParticipant;
use crate::storage::DiskStorage; use crate::storage::DiskStorage;
// shared application state accessible from all tauri commands // shared application state accessible from all tauri commands
@ -17,6 +20,8 @@ pub struct AppState {
pub crdt_engine: Arc<Mutex<CrdtEngine>>, pub crdt_engine: Arc<Mutex<CrdtEngine>>,
pub storage: Arc<DiskStorage>, pub storage: Arc<DiskStorage>,
pub node_handle: Arc<Mutex<Option<node::NodeHandle>>>, pub node_handle: Arc<Mutex<Option<node::NodeHandle>>>,
// tracks which peers are in which voice channels, keyed by "community_id:channel_id"
pub voice_channels: Arc<Mutex<HashMap<String, Vec<VoiceParticipant>>>>,
} }
impl AppState { impl AppState {
@ -36,6 +41,7 @@ impl AppState {
crdt_engine, crdt_engine,
storage, storage,
node_handle: Arc::new(Mutex::new(None)), node_handle: Arc::new(Mutex::new(None)),
voice_channels: Arc::new(Mutex::new(HashMap::new())),
} }
} }
} }
@ -48,6 +54,28 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.manage(AppState::new()) .manage(AppState::new())
.setup(|app| {
// grant microphone/camera permissions on linux webkitgtk
// without this, getUserMedia is denied by default
#[cfg(target_os = "linux")]
{
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
window
.with_webview(|webview| {
use webkit2gtk::PermissionRequestExt;
use webkit2gtk::WebViewExt;
let wv = webview.inner();
wv.connect_permission_request(|_webview, request| {
request.allow();
true
});
})
.ok();
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::identity::has_identity, commands::identity::has_identity,
commands::identity::load_identity, commands::identity::load_identity,
@ -67,6 +95,7 @@ pub fn run() {
commands::chat::send_typing, commands::chat::send_typing,
commands::chat::start_node, commands::chat::start_node,
commands::chat::stop_node, commands::chat::stop_node,
commands::chat::check_internet_connectivity,
commands::community::create_community, commands::community::create_community,
commands::community::join_community, commands::community::join_community,
commands::community::leave_community, commands::community::leave_community,
@ -77,6 +106,22 @@ pub fn run() {
commands::community::delete_message, commands::community::delete_message,
commands::community::kick_member, commands::community::kick_member,
commands::community::generate_invite, commands::community::generate_invite,
commands::community::reorder_channels,
commands::community::create_category,
commands::community::get_categories,
commands::voice::join_voice_channel,
commands::voice::leave_voice_channel,
commands::voice::update_voice_media_state,
commands::voice::send_voice_sdp,
commands::voice::send_voice_ice_candidate,
commands::voice::get_voice_participants,
commands::dm::send_dm,
commands::dm::get_dm_messages,
commands::dm::get_dm_conversations,
commands::dm::mark_dm_read,
commands::dm::delete_dm_conversation,
commands::dm::send_dm_typing,
commands::dm::open_dm_conversation,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running dusk"); .expect("error while running dusk");

View File

@ -32,3 +32,42 @@ pub fn topic_for_directory() -> String {
pub fn topic_for_sync() -> String { pub fn topic_for_sync() -> String {
"dusk/sync".to_string() "dusk/sync".to_string()
} }
// voice signaling topic for webrtc sdp/ice exchange and presence
pub fn topic_for_voice(community_id: &str, channel_id: &str) -> String {
format!(
"dusk/community/{}/channel/{}/voice",
community_id, channel_id
)
}
// personal inbox topic for receiving first-time dms from peers we haven't
// subscribed to yet. every peer subscribes to their own inbox on startup.
pub fn topic_for_dm_inbox(peer_id: &str) -> String {
format!("dusk/dm/inbox/{}", peer_id)
}
// dm topic between two peers, sorted alphabetically so both peers derive the same topic
pub fn topic_for_dm(peer_a: &str, peer_b: &str) -> String {
let (first, second) = if peer_a < peer_b {
(peer_a, peer_b)
} else {
(peer_b, peer_a)
};
format!("dusk/dm/{}/{}", first, second)
}
// derive a stable conversation id from two peer ids
pub fn dm_conversation_id(peer_a: &str, peer_b: &str) -> String {
let (first, second) = if peer_a < peer_b {
(peer_a, peer_b)
} else {
(peer_b, peer_a)
};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
first.hash(&mut hasher);
second.hash(&mut hasher);
format!("dm_{:016x}", hasher.finish())
}

View File

@ -3,7 +3,7 @@ pub mod discovery;
pub mod gossip; pub mod gossip;
pub mod swarm; pub mod swarm;
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use tauri::async_runtime::JoinHandle; use tauri::async_runtime::JoinHandle;
use tauri::Emitter; use tauri::Emitter;
@ -11,12 +11,11 @@ use tokio::sync::Mutex;
use crate::crdt::CrdtEngine; use crate::crdt::CrdtEngine;
use crate::protocol::identity::DirectoryEntry; use crate::protocol::identity::DirectoryEntry;
use crate::verification;
// default relay address - override with DUSK_RELAY_ADDR env var // default public relay - override with DUSK_RELAY_ADDR env var
// format: /ip4/<ip>/tcp/<port>/p2p/<peer_id> const DEFAULT_RELAY_ADDR: &str =
// left empty because 0.0.0.0 is a listen address, not a routable dial target. "/dns4/relay.duskchat.app/tcp/4001/p2p/12D3KooWGQkCkACcibJPKzus7Q6U1aYngfTuS4gwYwmJkJJtrSaw";
// users must set DUSK_RELAY_ADDR to a reachable relay for WAN connectivity.
const DEFAULT_RELAY_ADDR: &str = "";
// relay reconnection parameters // relay reconnection parameters
const RELAY_INITIAL_BACKOFF_SECS: u64 = 2; const RELAY_INITIAL_BACKOFF_SECS: u64 = 2;
@ -24,6 +23,9 @@ const RELAY_MAX_BACKOFF_SECS: u64 = 120;
const RELAY_BACKOFF_MULTIPLIER: u64 = 2; const RELAY_BACKOFF_MULTIPLIER: u64 = 2;
// max time to hold pending rendezvous registrations before discarding (10 min) // max time to hold pending rendezvous registrations before discarding (10 min)
const PENDING_QUEUE_TTL_SECS: u64 = 600; const PENDING_QUEUE_TTL_SECS: u64 = 600;
// grace period before warning the frontend about relay being down,
// prevents banner flashing on transient disconnections
const RELAY_WARN_GRACE_SECS: u64 = 8;
// resolve the relay multiaddr from env or default // resolve the relay multiaddr from env or default
fn relay_addr() -> Option<libp2p::Multiaddr> { fn relay_addr() -> Option<libp2p::Multiaddr> {
@ -119,6 +121,50 @@ pub enum DuskEvent {
}, },
#[serde(rename = "profile_revoked")] #[serde(rename = "profile_revoked")]
ProfileRevoked { peer_id: String }, ProfileRevoked { peer_id: String },
#[serde(rename = "relay_status")]
RelayStatus { connected: bool },
#[serde(rename = "voice_participant_joined")]
VoiceParticipantJoined {
community_id: String,
channel_id: String,
peer_id: String,
display_name: String,
media_state: crate::protocol::messages::VoiceMediaState,
},
#[serde(rename = "voice_participant_left")]
VoiceParticipantLeft {
community_id: String,
channel_id: String,
peer_id: String,
},
#[serde(rename = "voice_media_state_changed")]
VoiceMediaStateChanged {
community_id: String,
channel_id: String,
peer_id: String,
media_state: crate::protocol::messages::VoiceMediaState,
},
#[serde(rename = "voice_sdp_received")]
VoiceSdpReceived {
community_id: String,
channel_id: String,
from_peer: String,
sdp_type: String,
sdp: String,
},
#[serde(rename = "voice_ice_candidate_received")]
VoiceIceCandidateReceived {
community_id: String,
channel_id: String,
from_peer: String,
candidate: String,
sdp_mid: Option<String>,
sdp_mline_index: Option<u32>,
},
#[serde(rename = "dm_received")]
DMReceived(crate::protocol::messages::DirectMessage),
#[serde(rename = "dm_typing")]
DMTyping { peer_id: String },
} }
// extract the community id from a gossipsub topic string // extract the community id from a gossipsub topic string
@ -128,12 +174,17 @@ fn community_id_from_topic(topic: &str) -> Option<&str> {
.and_then(|rest| rest.split('/').next()) .and_then(|rest| rest.split('/').next())
} }
// voice channel participant tracking type alias for readability
pub type VoiceChannelMap =
Arc<Mutex<HashMap<String, Vec<crate::protocol::messages::VoiceParticipant>>>>;
// start the p2p node on a background task // start the p2p node on a background task
pub async fn start( pub async fn start(
keypair: libp2p::identity::Keypair, keypair: libp2p::identity::Keypair,
crdt_engine: Arc<Mutex<CrdtEngine>>, crdt_engine: Arc<Mutex<CrdtEngine>>,
storage: Arc<crate::storage::DiskStorage>, storage: Arc<crate::storage::DiskStorage>,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
voice_channels: VoiceChannelMap,
) -> Result<NodeHandle, String> { ) -> Result<NodeHandle, String> {
let mut swarm_instance = let mut swarm_instance =
swarm::build_swarm(&keypair).map_err(|e| format!("failed to build swarm: {}", e))?; swarm::build_swarm(&keypair).map_err(|e| format!("failed to build swarm: {}", e))?;
@ -159,6 +210,9 @@ pub async fn start(
let relay_peer_id = relay_multiaddr.as_ref().and_then(peer_id_from_multiaddr); let relay_peer_id = relay_multiaddr.as_ref().and_then(peer_id_from_multiaddr);
// if a relay is configured, dial it immediately // if a relay is configured, dial it immediately
// don't emit RelayStatus here -- the store defaults to connected=true so
// no warning shows during the initial handshake. the warning only appears
// if the dial actually fails (OutgoingConnectionError) or the connection drops.
if let Some(ref addr) = relay_multiaddr { if let Some(ref addr) = relay_multiaddr {
log::info!("dialing relay at {}", addr); log::info!("dialing relay at {}", addr);
if let Err(e) = swarm_instance.dial(addr.clone()) { if let Err(e) = swarm_instance.dial(addr.clone()) {
@ -172,6 +226,10 @@ pub async fn start(
// track connected peers for accurate count // track connected peers for accurate count
let mut connected_peers: HashSet<String> = HashSet::new(); let mut connected_peers: HashSet<String> = HashSet::new();
// dedup set for dm message ids -- messages arrive on both the pair topic
// and inbox topic so we need to skip duplicates
let mut seen_dm_ids: HashSet<String> = HashSet::new();
// track whether we have a relay reservation // track whether we have a relay reservation
let mut relay_reservation_active = false; let mut relay_reservation_active = false;
@ -193,6 +251,9 @@ pub async fn start(
// relay reconnection state with exponential backoff // relay reconnection state with exponential backoff
let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS; let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
// deferred warning timer -- only notify the frontend after the grace
// period expires so transient disconnections don't flash the banner
let mut relay_warn_at: Option<tokio::time::Instant> = None;
// next instant at which we should attempt a relay reconnect // next instant at which we should attempt a relay reconnect
let mut relay_retry_at: Option<tokio::time::Instant> = if relay_multiaddr.is_some() { let mut relay_retry_at: Option<tokio::time::Instant> = if relay_multiaddr.is_some() {
// schedule initial retry in case the first dial failed synchronously // schedule initial retry in case the first dial failed synchronously
@ -304,6 +365,18 @@ pub async fn start(
}); });
} }
crate::protocol::messages::GossipMessage::ProfileAnnounce(profile) => { crate::protocol::messages::GossipMessage::ProfileAnnounce(profile) => {
// reject announcements with invalid signatures
if !verification::verify_announcement(&profile.public_key, &profile) {
log::warn!("rejected unsigned/invalid profile from {}", profile.peer_id);
continue;
}
// reject unverified identities
if profile.verification_proof.is_none() {
log::warn!("rejected unverified profile from {}", profile.peer_id);
continue;
}
// cache the peer profile in our local directory // cache the peer profile in our local directory
let entry = DirectoryEntry { let entry = DirectoryEntry {
peer_id: profile.peer_id.clone(), peer_id: profile.peer_id.clone(),
@ -326,6 +399,12 @@ pub async fn start(
}); });
} }
crate::protocol::messages::GossipMessage::ProfileRevoke(revocation) => { crate::protocol::messages::GossipMessage::ProfileRevoke(revocation) => {
// reject revocations with invalid signatures
if !verification::verify_revocation(&revocation.public_key, &revocation) {
log::warn!("rejected unsigned revocation for {}", revocation.peer_id);
continue;
}
// peer is revoking their identity, remove them from our directory // peer is revoking their identity, remove them from our directory
let _ = storage.remove_directory_entry(&revocation.peer_id); let _ = storage.remove_directory_entry(&revocation.peer_id);
@ -333,6 +412,133 @@ pub async fn start(
peer_id: revocation.peer_id, peer_id: revocation.peer_id,
}); });
} }
crate::protocol::messages::GossipMessage::VoiceJoin {
community_id, channel_id, peer_id, display_name, media_state,
} => {
let participant = crate::protocol::messages::VoiceParticipant {
peer_id: peer_id.clone(),
display_name: display_name.clone(),
media_state: media_state.clone(),
};
// track the participant in shared voice state
let key = format!("{}:{}", community_id, channel_id);
let mut vc = voice_channels.lock().await;
let participants = vc.entry(key).or_insert_with(Vec::new);
// avoid duplicates if we receive a repeated join
participants.retain(|p| p.peer_id != peer_id);
participants.push(participant);
drop(vc);
let _ = app_handle.emit("dusk-event", DuskEvent::VoiceParticipantJoined {
community_id, channel_id, peer_id, display_name, media_state,
});
}
crate::protocol::messages::GossipMessage::VoiceLeave {
community_id, channel_id, peer_id,
} => {
let key = format!("{}:{}", community_id, channel_id);
let mut vc = voice_channels.lock().await;
if let Some(participants) = vc.get_mut(&key) {
participants.retain(|p| p.peer_id != peer_id);
if participants.is_empty() {
vc.remove(&key);
}
}
drop(vc);
let _ = app_handle.emit("dusk-event", DuskEvent::VoiceParticipantLeft {
community_id, channel_id, peer_id,
});
}
crate::protocol::messages::GossipMessage::VoiceMediaStateUpdate {
community_id, channel_id, peer_id, media_state,
} => {
// update tracked media state for this participant
let key = format!("{}:{}", community_id, channel_id);
let mut vc = voice_channels.lock().await;
if let Some(participants) = vc.get_mut(&key) {
if let Some(p) = participants.iter_mut().find(|p| p.peer_id == peer_id) {
p.media_state = media_state.clone();
}
}
drop(vc);
let _ = app_handle.emit("dusk-event", DuskEvent::VoiceMediaStateChanged {
community_id, channel_id, peer_id, media_state,
});
}
crate::protocol::messages::GossipMessage::VoiceSdp {
community_id, channel_id, from_peer, to_peer, sdp_type, sdp,
} => {
// only forward sdp messages addressed to us
let local_id = swarm_instance.local_peer_id().to_string();
if to_peer == local_id {
let _ = app_handle.emit("dusk-event", DuskEvent::VoiceSdpReceived {
community_id, channel_id, from_peer, sdp_type, sdp,
});
}
}
crate::protocol::messages::GossipMessage::VoiceIceCandidate {
community_id, channel_id, from_peer, to_peer, candidate, sdp_mid, sdp_mline_index,
} => {
// only forward ice candidates addressed to us
let local_id = swarm_instance.local_peer_id().to_string();
if to_peer == local_id {
let _ = app_handle.emit("dusk-event", DuskEvent::VoiceIceCandidateReceived {
community_id, channel_id, from_peer, candidate, sdp_mid, sdp_mline_index,
});
}
}
crate::protocol::messages::GossipMessage::DirectMessage(dm_msg) => {
// only process dms addressed to us (ignore our own echoes)
let local_id = swarm_instance.local_peer_id().to_string();
if dm_msg.to_peer == local_id {
// dedup: messages arrive on both the pair topic and inbox
// topic so skip if we've already processed this one
if !seen_dm_ids.insert(dm_msg.id.clone()) {
continue;
}
// cap the dedup set to prevent unbounded memory growth
if seen_dm_ids.len() > 10000 {
seen_dm_ids.clear();
}
// if this arrived on the inbox topic, the sender might be
// someone we've never dm'd before -- auto-subscribe to the
// pair topic so subsequent messages use the direct channel
if topic_str.starts_with("dusk/dm/inbox/") {
let pair_topic = gossip::topic_for_dm(&dm_msg.from_peer, &dm_msg.to_peer);
let ident_topic = libp2p::gossipsub::IdentTopic::new(pair_topic);
let _ = swarm_instance.behaviour_mut().gossipsub.subscribe(&ident_topic);
}
// persist the incoming message
let conversation_id = gossip::dm_conversation_id(&dm_msg.from_peer, &dm_msg.to_peer);
let _ = storage.append_dm_message(&conversation_id, &dm_msg);
// update or create conversation metadata
let existing = storage.load_dm_conversation(&conversation_id).ok();
let meta = crate::protocol::messages::DMConversationMeta {
peer_id: dm_msg.from_peer.clone(),
display_name: dm_msg.from_display_name.clone(),
last_message: Some(dm_msg.content.clone()),
last_message_time: Some(dm_msg.timestamp),
unread_count: existing.map(|m| m.unread_count + 1).unwrap_or(1),
};
let _ = storage.save_dm_conversation(&conversation_id, &meta);
let _ = app_handle.emit("dusk-event", DuskEvent::DMReceived(dm_msg));
}
}
crate::protocol::messages::GossipMessage::DMTyping(indicator) => {
let local_id = swarm_instance.local_peer_id().to_string();
if indicator.to_peer == local_id {
let _ = app_handle.emit("dusk-event", DuskEvent::DMTyping {
peer_id: indicator.from_peer,
});
}
}
} }
} }
} }
@ -388,6 +594,8 @@ pub async fn start(
)) => { )) => {
log::info!("relay reservation accepted by {}", relay_peer_id); log::info!("relay reservation accepted by {}", relay_peer_id);
relay_reservation_active = true; relay_reservation_active = true;
relay_warn_at = None;
let _ = app_handle.emit("dusk-event", DuskEvent::RelayStatus { connected: true });
// now that we have a relay reservation, process any pending // now that we have a relay reservation, process any pending
// rendezvous registrations that were queued before the relay was ready // rendezvous registrations that were queued before the relay was ready
@ -487,6 +695,13 @@ pub async fn start(
if Some(failed_peer) == relay_peer { if Some(failed_peer) == relay_peer {
log::warn!("failed to connect to relay: {}", error); log::warn!("failed to connect to relay: {}", error);
log::info!("scheduling relay reconnect in {}s", relay_backoff_secs); log::info!("scheduling relay reconnect in {}s", relay_backoff_secs);
// defer the warning so transient failures don't flash the banner
if relay_warn_at.is_none() {
relay_warn_at = Some(
tokio::time::Instant::now()
+ std::time::Duration::from_secs(RELAY_WARN_GRACE_SECS),
);
}
relay_retry_at = Some( relay_retry_at = Some(
tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs), tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs),
); );
@ -516,8 +731,11 @@ pub async fn start(
if Some(peer_id) == relay_peer && !relay_reservation_active { if Some(peer_id) == relay_peer && !relay_reservation_active {
// reset backoff on successful connection // reset backoff on successful connection
relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS; relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
// cancel any pending retry // cancel any pending retry and deferred warning
relay_retry_at = None; relay_retry_at = None;
relay_warn_at = None;
// clear the banner if it was already showing
let _ = app_handle.emit("dusk-event", DuskEvent::RelayStatus { connected: true });
if let Some(ref addr) = relay_multiaddr { if let Some(ref addr) = relay_multiaddr {
let relay_circuit_addr = addr.clone() let relay_circuit_addr = addr.clone()
@ -543,6 +761,33 @@ pub async fn start(
libp2p::swarm::SwarmEvent::ConnectionClosed { peer_id, num_established, .. } => { libp2p::swarm::SwarmEvent::ConnectionClosed { peer_id, num_established, .. } => {
if num_established == 0 { if num_established == 0 {
connected_peers.remove(&peer_id.to_string()); connected_peers.remove(&peer_id.to_string());
// remove disconnected peer from all voice channels and notify frontend
let peer_id_str = peer_id.to_string();
let mut vc = voice_channels.lock().await;
let mut empty_keys = Vec::new();
for (key, participants) in vc.iter_mut() {
let before_len = participants.len();
participants.retain(|p| p.peer_id != peer_id_str);
if participants.len() < before_len {
// parse the key back into community_id and channel_id
if let Some((cid, chid)) = key.split_once(':') {
let _ = app_handle.emit("dusk-event", DuskEvent::VoiceParticipantLeft {
community_id: cid.to_string(),
channel_id: chid.to_string(),
peer_id: peer_id_str.clone(),
});
}
}
if participants.is_empty() {
empty_keys.push(key.clone());
}
}
for key in empty_keys {
vc.remove(&key);
}
drop(vc);
let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected { let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected {
peer_id: peer_id.to_string(), peer_id: peer_id.to_string(),
}); });
@ -556,6 +801,13 @@ pub async fn start(
if Some(peer_id) == relay_peer { if Some(peer_id) == relay_peer {
relay_reservation_active = false; relay_reservation_active = false;
log::warn!("lost connection to relay, scheduling reconnect in {}s", relay_backoff_secs); log::warn!("lost connection to relay, scheduling reconnect in {}s", relay_backoff_secs);
// defer the warning so quick reconnections don't flash the banner
if relay_warn_at.is_none() {
relay_warn_at = Some(
tokio::time::Instant::now()
+ std::time::Duration::from_secs(RELAY_WARN_GRACE_SECS),
);
}
relay_retry_at = Some( relay_retry_at = Some(
tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs), tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs),
@ -626,6 +878,18 @@ pub async fn start(
} }
} }
// deferred relay warning -- only tell the frontend after the grace
// period so transient disconnections don't flash the banner
_ = tokio::time::sleep_until(
relay_warn_at.unwrap_or_else(|| tokio::time::Instant::now() + std::time::Duration::from_secs(86400))
), if relay_warn_at.is_some() => {
relay_warn_at = None;
// grace period expired and we still don't have a relay connection
if !relay_reservation_active {
let _ = app_handle.emit("dusk-event", DuskEvent::RelayStatus { connected: false });
}
}
cmd = command_rx.recv() => { cmd = command_rx.recv() => {
match cmd { match cmd {
Some(NodeCommand::Shutdown) | None => break, Some(NodeCommand::Shutdown) | None => break,

View File

@ -41,6 +41,8 @@ pub fn build_swarm(
noise::Config::new, noise::Config::new,
yamux::Config::default, yamux::Config::default,
)? )?
// resolve dns4/dns6 multiaddrs (needed for relay.duskchat.app)
.with_dns()?
// add relay client transport so we can connect through relay circuits // add relay client transport so we can connect through relay circuits
.with_relay_client(noise::Config::new, yamux::Config::default)? .with_relay_client(noise::Config::new, yamux::Config::default)?
.with_behaviour(|key, relay_client| { .with_behaviour(|key, relay_client| {
@ -71,10 +73,11 @@ pub fn build_swarm(
kademlia, kademlia,
mdns, mdns,
identify, identify,
ping: ping::Behaviour::default(), // ping every 30s to keep the relay connection alive
ping: ping::Behaviour::new(ping::Config::new().with_interval(Duration::from_secs(30))),
} }
})? })?
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(60))) .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300)))
.build(); .build();
Ok(swarm) Ok(swarm)

View File

@ -9,6 +9,15 @@ pub struct CommunityMeta {
pub created_at: u64, pub created_at: u64,
} }
// user-defined grouping for channels within a community
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategoryMeta {
pub id: String,
pub community_id: String,
pub name: String,
pub position: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelMeta { pub struct ChannelMeta {
pub id: String, pub id: String,
@ -16,6 +25,9 @@ pub struct ChannelMeta {
pub name: String, pub name: String,
pub topic: String, pub topic: String,
pub kind: ChannelKind, pub kind: ChannelKind,
pub position: u32,
// channels without a category sit at the top level
pub category_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -11,6 +11,7 @@ pub struct DuskIdentity {
pub display_name: String, pub display_name: String,
pub bio: String, pub bio: String,
pub created_at: u64, pub created_at: u64,
pub verification_proof: Option<VerificationProof>,
} }
impl DuskIdentity { impl DuskIdentity {
@ -29,6 +30,7 @@ impl DuskIdentity {
display_name: display_name.to_string(), display_name: display_name.to_string(),
bio: bio.to_string(), bio: bio.to_string(),
created_at, created_at,
verification_proof: None,
} }
} }
@ -44,6 +46,7 @@ impl DuskIdentity {
let peer_id = PeerId::from(keypair.public()); let peer_id = PeerId::from(keypair.public());
let profile = storage.load_profile().unwrap_or_default(); let profile = storage.load_profile().unwrap_or_default();
let verification_proof = storage.load_verification_proof().ok().flatten();
Ok(Self { Ok(Self {
keypair, keypair,
@ -51,6 +54,7 @@ impl DuskIdentity {
display_name: profile.display_name, display_name: profile.display_name,
bio: profile.bio, bio: profile.bio,
created_at: profile.created_at, created_at: profile.created_at,
verification_proof,
}) })
} }
@ -86,6 +90,7 @@ impl DuskIdentity {
public_key: hex::encode(public_key_bytes), public_key: hex::encode(public_key_bytes),
bio: self.bio.clone(), bio: self.bio.clone(),
created_at: self.created_at, created_at: self.created_at,
verification_proof: self.verification_proof.clone(),
} }
} }
} }
@ -97,6 +102,17 @@ pub struct PublicIdentity {
pub public_key: String, pub public_key: String,
pub bio: String, pub bio: String,
pub created_at: u64, pub created_at: u64,
pub verification_proof: Option<VerificationProof>,
}
// cryptographic proof that the identity was created through human verification
// the signature binds this proof to a specific keypair so it cannot be reused
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationProof {
pub metrics_hash: String,
pub signature: String,
pub timestamp: u64,
pub score: f64,
} }
// profile data stored on disk alongside the keypair // profile data stored on disk alongside the keypair

View File

@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::identity::VerificationProof;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage { pub struct ChatMessage {
pub id: String, pub id: String,
@ -34,6 +36,8 @@ pub enum PeerStatus {
} }
// peer profile announcement broadcast on the directory topic // peer profile announcement broadcast on the directory topic
// includes a verification proof and a signature over all fields
// so peers can reject unverified or spoofed identities
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileAnnouncement { pub struct ProfileAnnouncement {
pub peer_id: String, pub peer_id: String,
@ -41,14 +45,64 @@ pub struct ProfileAnnouncement {
pub bio: String, pub bio: String,
pub public_key: String, pub public_key: String,
pub timestamp: u64, pub timestamp: u64,
pub verification_proof: Option<VerificationProof>,
pub signature: String,
} }
// broadcast when a user resets their identity, tells peers to purge their data // broadcast when a user resets their identity, tells peers to purge their data
// signed to prevent unauthorized revocation of another peer's identity
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileRevocation { pub struct ProfileRevocation {
pub peer_id: String, pub peer_id: String,
pub public_key: String, pub public_key: String,
pub timestamp: u64, pub timestamp: u64,
pub signature: String,
}
// media state for a participant in a voice channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceMediaState {
pub muted: bool,
pub deafened: bool,
pub video_enabled: bool,
pub screen_sharing: bool,
}
// a peer currently connected to a voice channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceParticipant {
pub peer_id: String,
pub display_name: String,
pub media_state: VoiceMediaState,
}
// a direct message between two peers
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectMessage {
pub id: String,
pub from_peer: String,
pub to_peer: String,
pub from_display_name: String,
pub content: String,
pub timestamp: u64,
}
// typing indicator scoped to a dm conversation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DMTypingIndicator {
pub from_peer: String,
pub to_peer: String,
pub timestamp: u64,
}
// metadata for a persisted dm conversation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DMConversationMeta {
pub peer_id: String,
pub display_name: String,
pub last_message: Option<String>,
pub last_message_time: Option<u64>,
pub unread_count: u32,
} }
// envelope for all gossipsub-published messages // envelope for all gossipsub-published messages
@ -58,8 +112,49 @@ pub enum GossipMessage {
Typing(TypingIndicator), Typing(TypingIndicator),
Presence(PresenceUpdate), Presence(PresenceUpdate),
MetaUpdate(super::community::CommunityMeta), MetaUpdate(super::community::CommunityMeta),
DeleteMessage { message_id: String }, DeleteMessage {
MemberKicked { peer_id: String }, message_id: String,
},
MemberKicked {
peer_id: String,
},
ProfileAnnounce(ProfileAnnouncement), ProfileAnnounce(ProfileAnnouncement),
ProfileRevoke(ProfileRevocation), ProfileRevoke(ProfileRevocation),
DirectMessage(DirectMessage),
DMTyping(DMTypingIndicator),
VoiceJoin {
community_id: String,
channel_id: String,
peer_id: String,
display_name: String,
media_state: VoiceMediaState,
},
VoiceLeave {
community_id: String,
channel_id: String,
peer_id: String,
},
VoiceMediaStateUpdate {
community_id: String,
channel_id: String,
peer_id: String,
media_state: VoiceMediaState,
},
VoiceSdp {
community_id: String,
channel_id: String,
from_peer: String,
to_peer: String,
sdp_type: String,
sdp: String,
},
VoiceIceCandidate {
community_id: String,
channel_id: String,
from_peer: String,
to_peer: String,
candidate: String,
sdp_mid: Option<String>,
sdp_mline_index: Option<u32>,
},
} }

View File

@ -6,7 +6,8 @@ use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use crate::protocol::community::CommunityMeta; use crate::protocol::community::CommunityMeta;
use crate::protocol::identity::{DirectoryEntry, ProfileData}; use crate::protocol::identity::{DirectoryEntry, ProfileData, VerificationProof};
use crate::protocol::messages::{DMConversationMeta, DirectMessage};
// user settings that persist across sessions // user settings that persist across sessions
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -56,6 +57,7 @@ impl DiskStorage {
fs::create_dir_all(base_dir.join("identity"))?; fs::create_dir_all(base_dir.join("identity"))?;
fs::create_dir_all(base_dir.join("communities"))?; fs::create_dir_all(base_dir.join("communities"))?;
fs::create_dir_all(base_dir.join("directory"))?; fs::create_dir_all(base_dir.join("directory"))?;
fs::create_dir_all(base_dir.join("dms"))?;
Ok(Self { base_dir }) Ok(Self { base_dir })
} }
@ -110,6 +112,25 @@ impl DiskStorage {
self.base_dir.join("identity/keypair.bin").exists() self.base_dir.join("identity/keypair.bin").exists()
} }
// -- verification proof --
pub fn save_verification_proof(&self, proof: &VerificationProof) -> Result<(), io::Error> {
let json = serde_json::to_string_pretty(proof)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(self.base_dir.join("identity/verification.json"), json)
}
pub fn load_verification_proof(&self) -> Result<Option<VerificationProof>, io::Error> {
let path = self.base_dir.join("identity/verification.json");
if !path.exists() {
return Ok(None);
}
let data = fs::read_to_string(path)?;
let proof = serde_json::from_str(&data)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(proof))
}
// -- automerge documents -- // -- automerge documents --
pub fn save_document(&self, community_id: &str, doc_bytes: &[u8]) -> Result<(), io::Error> { pub fn save_document(&self, community_id: &str, doc_bytes: &[u8]) -> Result<(), io::Error> {
@ -225,7 +246,131 @@ impl DiskStorage {
} }
} }
// wipe all user data - identity, communities, directory, settings // -- direct messages --
// save a dm conversation's metadata
pub fn save_dm_conversation(
&self,
conversation_id: &str,
meta: &DMConversationMeta,
) -> Result<(), io::Error> {
let dir = self.base_dir.join(format!("dms/{}", conversation_id));
fs::create_dir_all(&dir)?;
let json = serde_json::to_string_pretty(meta)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(dir.join("meta.json"), json)
}
// load a single dm conversation's metadata
pub fn load_dm_conversation(
&self,
conversation_id: &str,
) -> Result<DMConversationMeta, io::Error> {
let path = self
.base_dir
.join(format!("dms/{}/meta.json", conversation_id));
let data = fs::read_to_string(path)?;
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
// load all dm conversations
pub fn load_all_dm_conversations(
&self,
) -> Result<Vec<(String, DMConversationMeta)>, io::Error> {
let dms_dir = self.base_dir.join("dms");
if !dms_dir.exists() {
return Ok(Vec::new());
}
let mut conversations = Vec::new();
for entry in fs::read_dir(dms_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(conv_id) = entry.file_name().to_str() {
let meta_path = entry.path().join("meta.json");
if meta_path.exists() {
if let Ok(data) = fs::read_to_string(&meta_path) {
if let Ok(meta) = serde_json::from_str::<DMConversationMeta>(&data) {
conversations.push((conv_id.to_string(), meta));
}
}
}
}
}
}
Ok(conversations)
}
// remove a dm conversation and all its messages
pub fn remove_dm_conversation(&self, conversation_id: &str) -> Result<(), io::Error> {
let dir = self.base_dir.join(format!("dms/{}", conversation_id));
if dir.exists() {
fs::remove_dir_all(&dir)?;
}
Ok(())
}
// append a message to a dm conversation's message log
pub fn append_dm_message(
&self,
conversation_id: &str,
message: &DirectMessage,
) -> Result<(), io::Error> {
let dir = self.base_dir.join(format!("dms/{}", conversation_id));
fs::create_dir_all(&dir)?;
let messages_path = dir.join("messages.json");
let mut messages: Vec<DirectMessage> = if messages_path.exists() {
let data = fs::read_to_string(&messages_path)?;
serde_json::from_str(&data).unwrap_or_default()
} else {
Vec::new()
};
messages.push(message.clone());
let json = serde_json::to_string_pretty(&messages)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(&messages_path, json)
}
// load dm messages with optional pagination
pub fn load_dm_messages(
&self,
conversation_id: &str,
before: Option<u64>,
limit: usize,
) -> Result<Vec<DirectMessage>, io::Error> {
let messages_path = self
.base_dir
.join(format!("dms/{}/messages.json", conversation_id));
if !messages_path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(&messages_path)?;
let messages: Vec<DirectMessage> = serde_json::from_str(&data)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let filtered: Vec<DirectMessage> = if let Some(before_ts) = before {
messages
.into_iter()
.filter(|m| m.timestamp < before_ts)
.collect()
} else {
messages
};
// return the last `limit` messages (most recent)
let start = if filtered.len() > limit {
filtered.len() - limit
} else {
0
};
Ok(filtered[start..].to_vec())
}
// wipe all user data - identity, communities, directory, dms, settings
// used when resetting identity to leave no traces on this client // used when resetting identity to leave no traces on this client
pub fn wipe_all_data(&self) -> Result<(), io::Error> { pub fn wipe_all_data(&self) -> Result<(), io::Error> {
let identity_dir = self.base_dir.join("identity"); let identity_dir = self.base_dir.join("identity");
@ -243,10 +388,16 @@ impl DiskStorage {
fs::remove_dir_all(&directory_dir)?; fs::remove_dir_all(&directory_dir)?;
} }
let dms_dir = self.base_dir.join("dms");
if dms_dir.exists() {
fs::remove_dir_all(&dms_dir)?;
}
// recreate the directory tree so the app can still function // recreate the directory tree so the app can still function
fs::create_dir_all(self.base_dir.join("identity"))?; fs::create_dir_all(self.base_dir.join("identity"))?;
fs::create_dir_all(self.base_dir.join("communities"))?; fs::create_dir_all(self.base_dir.join("communities"))?;
fs::create_dir_all(self.base_dir.join("directory"))?; fs::create_dir_all(self.base_dir.join("directory"))?;
fs::create_dir_all(self.base_dir.join("dms"))?;
Ok(()) Ok(())
} }

View File

@ -0,0 +1,436 @@
use std::time::{SystemTime, UNIX_EPOCH};
use libp2p::identity;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::protocol::identity::VerificationProof;
use crate::protocol::messages::{ProfileAnnouncement, ProfileRevocation};
// -- challenge data structures received from the frontend --
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MouseSample {
pub x: f64,
pub y: f64,
pub t: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SegmentData {
pub from_target: u32,
pub to_target: u32,
pub samples: Vec<MouseSample>,
pub click_time: f64,
pub start_time: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetCircle {
pub id: u32,
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChallengeSubmission {
pub segments: Vec<SegmentData>,
pub circles: Vec<TargetCircle>,
pub total_start_time: f64,
pub total_end_time: f64,
}
pub struct AnalysisResult {
pub is_human: bool,
pub score: f64,
}
const HUMAN_THRESHOLD: f64 = 0.35;
// -- behavioral analysis functions --
// these mirror the typescript implementations exactly, running in compiled rust
// so the analysis logic is not exposed in the inspectable webview
fn score_timing_variance(segments: &[SegmentData]) -> f64 {
if segments.len() < 2 {
return 0.0;
}
let intervals: Vec<f64> = segments.iter().map(|s| s.click_time - s.start_time).collect();
let mean = intervals.iter().sum::<f64>() / intervals.len() as f64;
if mean == 0.0 {
return 0.0;
}
let variance = intervals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / intervals.len() as f64;
let cv = variance.sqrt() / mean;
// humans have natural variance in click timing
// bots tend to be metronomic or instantaneous
if cv < 0.03 {
0.0
} else if cv < 0.08 {
0.3
} else if cv < 0.12 {
0.6
} else {
1.0
}
}
fn score_path_curvature(segments: &[SegmentData]) -> f64 {
let mut ratios = Vec::new();
for seg in segments {
if seg.samples.len() < 3 {
continue;
}
let first = &seg.samples[0];
let last = &seg.samples[seg.samples.len() - 1];
let straight_dist = ((last.x - first.x).powi(2) + (last.y - first.y).powi(2)).sqrt();
// skip very short movements where curvature is meaningless
if straight_dist < 10.0 {
continue;
}
let mut path_length = 0.0;
for i in 1..seg.samples.len() {
let dx = seg.samples[i].x - seg.samples[i - 1].x;
let dy = seg.samples[i].y - seg.samples[i - 1].y;
path_length += (dx * dx + dy * dy).sqrt();
}
ratios.push(path_length / straight_dist);
}
if ratios.is_empty() {
return 0.0;
}
let avg_ratio = ratios.iter().sum::<f64>() / ratios.len() as f64;
// humans never move in perfectly straight lines
if avg_ratio < 1.02 {
0.0
} else if avg_ratio < 1.06 {
0.3
} else if avg_ratio < 1.10 {
0.6
} else if avg_ratio > 4.0 {
0.5
} else {
1.0
}
}
fn score_speed_variance(segments: &[SegmentData]) -> f64 {
let mut all_speed_cvs = Vec::new();
for seg in segments {
if seg.samples.len() < 5 {
continue;
}
let mut speeds = Vec::new();
for i in 1..seg.samples.len() {
let dx = seg.samples[i].x - seg.samples[i - 1].x;
let dy = seg.samples[i].y - seg.samples[i - 1].y;
let dt = seg.samples[i].t - seg.samples[i - 1].t;
if dt > 0.0 {
speeds.push((dx * dx + dy * dy).sqrt() / dt);
}
}
if speeds.len() < 3 {
continue;
}
let mean = speeds.iter().sum::<f64>() / speeds.len() as f64;
if mean == 0.0 {
continue;
}
let variance = speeds.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / speeds.len() as f64;
let cv = variance.sqrt() / mean;
all_speed_cvs.push(cv);
}
if all_speed_cvs.is_empty() {
return 0.0;
}
let avg_cv = all_speed_cvs.iter().sum::<f64>() / all_speed_cvs.len() as f64;
// humans accelerate and decelerate naturally
if avg_cv < 0.1 {
0.0
} else if avg_cv < 0.25 {
0.4
} else if avg_cv < 0.4 {
0.7
} else {
1.0
}
}
fn score_approach_jitter(segments: &[SegmentData], circles: &[TargetCircle]) -> f64 {
let mut jitter_scores = Vec::new();
for (i, seg) in segments.iter().enumerate() {
if i >= circles.len() || seg.samples.len() < 5 {
continue;
}
let target = &circles[i];
// isolate the last stretch approaching the target
let approach_samples: Vec<&MouseSample> = seg
.samples
.iter()
.filter(|s| {
let dx = s.x - target.x;
let dy = s.y - target.y;
(dx * dx + dy * dy).sqrt() < 60.0
})
.collect();
if approach_samples.len() < 4 {
continue;
}
// count direction changes via cross product sign flips
let mut direction_changes = 0u32;
for j in 2..approach_samples.len() {
let dx1 = approach_samples[j - 1].x - approach_samples[j - 2].x;
let dy1 = approach_samples[j - 1].y - approach_samples[j - 2].y;
let dx2 = approach_samples[j].x - approach_samples[j - 1].x;
let dy2 = approach_samples[j].y - approach_samples[j - 1].y;
let cross = dx1 * dy2 - dy1 * dx2;
if j > 2 {
let prev_dx1 = approach_samples[j - 2].x - approach_samples[j - 3].x;
let prev_dy1 = approach_samples[j - 2].y - approach_samples[j - 3].y;
let prev_cross = prev_dx1 * dy1 - prev_dy1 * dx1;
if cross * prev_cross < 0.0 {
direction_changes += 1;
}
}
}
let jitter_ratio = direction_changes as f64 / (approach_samples.len() - 2).max(1) as f64;
jitter_scores.push(jitter_ratio);
}
// not enough data to judge, give a neutral score
if jitter_scores.is_empty() {
return 0.5;
}
let avg_jitter = jitter_scores.iter().sum::<f64>() / jitter_scores.len() as f64;
// humans have micro-corrections from motor noise
if avg_jitter < 0.01 {
0.2
} else if avg_jitter < 0.05 {
0.5
} else {
1.0
}
}
fn score_overall_timing(total_start: f64, total_end: f64) -> f64 {
let total_sec = (total_end - total_start) / 1000.0;
if total_sec < 0.8 {
0.0
} else if total_sec < 1.5 {
0.3
} else if total_sec > 60.0 {
0.5
} else {
1.0
}
}
pub fn analyze_challenge(data: &ChallengeSubmission) -> AnalysisResult {
let timing = score_timing_variance(&data.segments);
let curvature = score_path_curvature(&data.segments);
let speed = score_speed_variance(&data.segments);
let jitter = score_approach_jitter(&data.segments, &data.circles);
let overall = score_overall_timing(data.total_start_time, data.total_end_time);
let score = timing * 0.25 + curvature * 0.25 + speed * 0.20 + jitter * 0.20 + overall * 0.10;
AnalysisResult {
is_human: score >= HUMAN_THRESHOLD,
score,
}
}
// -- proof generation --
pub fn generate_proof(
challenge: &ChallengeSubmission,
keypair: &identity::Keypair,
peer_id: &str,
) -> Result<VerificationProof, String> {
let result = analyze_challenge(challenge);
if !result.is_human {
return Err("behavioral analysis did not pass human threshold".to_string());
}
// hash the raw challenge data to create a fingerprint
let challenge_bytes =
serde_json::to_vec(challenge).map_err(|e| format!("failed to serialize challenge: {}", e))?;
let mut hasher = Sha256::new();
hasher.update(&challenge_bytes);
let metrics_hash = hex::encode(hasher.finalize());
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
// sign (metrics_hash || peer_id || timestamp) to bind the proof to this keypair
let sign_payload = format!("{}||{}||{}", metrics_hash, peer_id, timestamp);
let signature = keypair
.sign(sign_payload.as_bytes())
.map_err(|e| format!("failed to sign proof: {}", e))?;
Ok(VerificationProof {
metrics_hash,
signature: hex::encode(signature),
timestamp,
score: result.score,
})
}
// -- profile announcement signing --
// build the canonical payload that gets signed for an announcement
fn announcement_sign_payload(
peer_id: &str,
display_name: &str,
bio: &str,
public_key: &str,
timestamp: u64,
metrics_hash: &str,
) -> Vec<u8> {
format!(
"dusk-announce||{}||{}||{}||{}||{}||{}",
peer_id, display_name, bio, public_key, timestamp, metrics_hash
)
.into_bytes()
}
pub fn sign_announcement(keypair: &identity::Keypair, announcement: &ProfileAnnouncement) -> String {
let metrics_hash = announcement
.verification_proof
.as_ref()
.map(|p| p.metrics_hash.as_str())
.unwrap_or("");
let payload = announcement_sign_payload(
&announcement.peer_id,
&announcement.display_name,
&announcement.bio,
&announcement.public_key,
announcement.timestamp,
metrics_hash,
);
match keypair.sign(&payload) {
Ok(sig) => hex::encode(sig),
Err(e) => {
log::error!("failed to sign announcement: {}", e);
String::new()
}
}
}
pub fn verify_announcement(public_key_hex: &str, announcement: &ProfileAnnouncement) -> bool {
let pk_bytes = match hex::decode(public_key_hex) {
Ok(b) => b,
Err(_) => return false,
};
let public_key = match identity::PublicKey::try_decode_protobuf(&pk_bytes) {
Ok(pk) => pk,
Err(_) => return false,
};
let sig_bytes = match hex::decode(&announcement.signature) {
Ok(b) => b,
Err(_) => return false,
};
let metrics_hash = announcement
.verification_proof
.as_ref()
.map(|p| p.metrics_hash.as_str())
.unwrap_or("");
let payload = announcement_sign_payload(
&announcement.peer_id,
&announcement.display_name,
&announcement.bio,
&announcement.public_key,
announcement.timestamp,
metrics_hash,
);
public_key.verify(&payload, &sig_bytes)
}
// -- profile revocation signing --
fn revocation_sign_payload(peer_id: &str, public_key: &str, timestamp: u64) -> Vec<u8> {
format!("dusk-revoke||{}||{}||{}", peer_id, public_key, timestamp).into_bytes()
}
pub fn sign_revocation(keypair: &identity::Keypair, revocation: &ProfileRevocation) -> String {
let payload = revocation_sign_payload(
&revocation.peer_id,
&revocation.public_key,
revocation.timestamp,
);
match keypair.sign(&payload) {
Ok(sig) => hex::encode(sig),
Err(e) => {
log::error!("failed to sign revocation: {}", e);
String::new()
}
}
}
pub fn verify_revocation(public_key_hex: &str, revocation: &ProfileRevocation) -> bool {
let pk_bytes = match hex::decode(public_key_hex) {
Ok(b) => b,
Err(_) => return false,
};
let public_key = match identity::PublicKey::try_decode_protobuf(&pk_bytes) {
Ok(pk) => pk,
Err(_) => return false,
};
let sig_bytes = match hex::decode(&revocation.signature) {
Ok(b) => b,
Err(_) => return false,
};
let payload = revocation_sign_payload(
&revocation.peer_id,
&revocation.public_key,
revocation.timestamp,
);
public_key.verify(&payload, &sig_bytes)
}

View File

@ -1,4 +1,13 @@
import { Component, onMount, onCleanup, createSignal, Show } from "solid-js"; import {
Component,
onMount,
onCleanup,
createSignal,
createEffect,
on,
Show,
For,
} from "solid-js";
import AppLayout from "./components/layout/AppLayout"; import AppLayout from "./components/layout/AppLayout";
import OverlayMenu from "./components/navigation/OverlayMenu"; import OverlayMenu from "./components/navigation/OverlayMenu";
import MobileNav from "./components/navigation/MobileNav"; import MobileNav from "./components/navigation/MobileNav";
@ -6,7 +15,10 @@ import Modal from "./components/common/Modal";
import Button from "./components/common/Button"; import Button from "./components/common/Button";
import SettingsModal from "./components/settings/SettingsModal"; import SettingsModal from "./components/settings/SettingsModal";
import SignUpScreen from "./components/auth/SignUpScreen"; import SignUpScreen from "./components/auth/SignUpScreen";
import SplashScreen from "./components/auth/SplashScreen";
import UserDirectoryModal from "./components/directory/UserDirectoryModal"; import UserDirectoryModal from "./components/directory/UserDirectoryModal";
import ProfileCard from "./components/common/ProfileCard";
import ProfileModal from "./components/common/ProfileModal";
import { import {
overlayMenuOpen, overlayMenuOpen,
@ -28,6 +40,9 @@ import {
setChannels, setChannels,
setActiveChannel, setActiveChannel,
activeChannelId, activeChannelId,
setCategories,
addCategory,
categories,
} from "./stores/channels"; } from "./stores/channels";
import { import {
addMessage, addMessage,
@ -46,6 +61,8 @@ import {
setPeerCount, setPeerCount,
setNodeStatus, setNodeStatus,
setIsConnected, setIsConnected,
setRelayConnected,
relayConnected,
} from "./stores/connection"; } from "./stores/connection";
import { import {
setDMConversations, setDMConversations,
@ -53,6 +70,12 @@ import {
addDMMessage, addDMMessage,
setActiveDM, setActiveDM,
updateDMLastMessage, updateDMLastMessage,
handleIncomingDM,
addDMTypingPeer,
clearDMTypingPeers,
clearDMMessages,
setDMMessages,
updateDMPeerDisplayName,
} from "./stores/dms"; } from "./stores/dms";
import { import {
setKnownPeers, setKnownPeers,
@ -61,9 +84,21 @@ import {
removePeer, removePeer,
clearDirectory, clearDirectory,
} from "./stores/directory"; } from "./stores/directory";
import {
handleVoiceParticipantJoined,
handleVoiceParticipantLeft,
handleVoiceMediaStateChanged,
handleVoiceSdpReceived,
handleVoiceIceCandidateReceived,
} from "./stores/voice";
import * as tauri from "./lib/tauri"; import * as tauri from "./lib/tauri";
import type { DuskEvent } from "./lib/types"; import type {
DuskEvent,
ChallengeExport,
ChannelMeta,
DirectMessage,
} from "./lib/types";
import { resetSettings } from "./stores/settings"; import { resetSettings } from "./stores/settings";
const App: Component = () => { const App: Component = () => {
@ -73,11 +108,101 @@ const App: Component = () => {
const [tauriAvailable, setTauriAvailable] = createSignal(false); const [tauriAvailable, setTauriAvailable] = createSignal(false);
const [needsSignUp, setNeedsSignUp] = createSignal(false); const [needsSignUp, setNeedsSignUp] = createSignal(false);
const [appReady, setAppReady] = createSignal(false); const [appReady, setAppReady] = createSignal(false);
const [showSplash, setShowSplash] = createSignal(true);
const [newCommunityName, setNewCommunityName] = createSignal(""); const [newCommunityName, setNewCommunityName] = createSignal("");
const [newCommunityDesc, setNewCommunityDesc] = createSignal(""); const [newCommunityDesc, setNewCommunityDesc] = createSignal("");
const [joinInviteCode, setJoinInviteCode] = createSignal(""); const [joinInviteCode, setJoinInviteCode] = createSignal("");
const [newChannelName, setNewChannelName] = createSignal(""); const [newChannelName, setNewChannelName] = createSignal("");
const [newChannelTopic, setNewChannelTopic] = createSignal(""); const [newChannelTopic, setNewChannelTopic] = createSignal("");
const [newChannelKind, setNewChannelKind] = createSignal<"Text" | "Voice">(
"Text",
);
const [newChannelCategoryId, setNewChannelCategoryId] = createSignal<
string | null
>(null);
const [newCategoryName, setNewCategoryName] = createSignal("");
// react to community switches by loading channels, members, and selecting first channel
createEffect(
on(activeCommunityId, async (communityId, prev) => {
if (communityId === prev) return;
if (!communityId) {
setChannels([]);
setCategories([]);
setActiveChannel(null);
clearMessages();
setMembers([]);
return;
}
if (tauriAvailable()) {
try {
const [chs, cats] = await Promise.all([
tauri.getChannels(communityId),
tauri.getCategories(communityId),
]);
setChannels(chs);
setCategories(cats);
if (chs.length > 0) {
setActiveChannel(chs[0].id);
} else {
setActiveChannel(null);
clearMessages();
}
const mems = await tauri.getMembers(communityId);
setMembers(mems);
} catch (e) {
console.error("failed to load community data:", e);
}
}
}),
);
// react to channel switches by loading messages for the new channel
createEffect(
on(activeChannelId, async (channelId, prev) => {
if (channelId === prev) return;
if (!channelId) {
clearMessages();
return;
}
if (tauriAvailable()) {
try {
clearMessages();
const msgs = await tauri.getMessages(channelId);
setMessages(msgs);
} catch (e) {
console.error("failed to load messages:", e);
}
}
}),
);
// react to dm switches by loading messages for the selected peer
createEffect(
on(activeDMPeerId, async (peerId, prev) => {
if (peerId === prev) return;
clearDMTypingPeers();
if (!peerId) {
clearDMMessages();
return;
}
if (tauriAvailable()) {
try {
clearDMMessages();
const msgs = await tauri.getDMMessages(peerId);
setDMMessages(msgs);
} catch (e) {
console.error("failed to load dm messages:", e);
}
}
}),
);
onMount(async () => { onMount(async () => {
cleanupResize = initResponsive(); cleanupResize = initResponsive();
@ -142,6 +267,14 @@ const App: Component = () => {
// directory not populated yet, that's fine // directory not populated yet, that's fine
} }
// load existing dm conversations from disk
try {
const convos = await tauri.getDMConversations();
setDMConversations(convos);
} catch {
// no dm history yet, that's fine
}
const communities = await tauri.getCommunities(); const communities = await tauri.getCommunities();
setCommunities(communities); setCommunities(communities);
@ -157,19 +290,10 @@ const App: Component = () => {
// from the backend will set the accurate state once peers are found. // from the backend will set the accurate state once peers are found.
setNodeStatus("running"); setNodeStatus("running");
// the createEffect on activeCommunityId handles loading channels,
// messages, and members reactively when this is set
if (communities.length > 0) { if (communities.length > 0) {
setActiveCommunity(communities[0].id); setActiveCommunity(communities[0].id);
const channels = await tauri.getChannels(communities[0].id);
setChannels(channels);
if (channels.length > 0) {
setActiveChannel(channels[0].id);
const messages = await tauri.getMessages(channels[0].id);
setMessages(messages);
}
const members = await tauri.getMembers(communities[0].id);
setMembers(members);
} }
} catch (e) { } catch (e) {
console.error("initialization error:", e); console.error("initialization error:", e);
@ -219,11 +343,43 @@ const App: Component = () => {
event.payload.display_name, event.payload.display_name,
event.payload.bio, event.payload.bio,
); );
// keep dm conversation names in sync
updateDMPeerDisplayName(
event.payload.peer_id,
event.payload.display_name,
);
break; break;
case "profile_revoked": case "profile_revoked":
// peer revoked their identity, remove them from our local directory // peer revoked their identity, remove them from our local directory
removePeer(event.payload.peer_id); removePeer(event.payload.peer_id);
break; break;
case "relay_status":
setRelayConnected(event.payload.connected);
break;
case "dm_received":
handleIncomingDM(event.payload);
break;
case "dm_typing":
// only show typing if the sender is the active dm peer
if (event.payload.peer_id === activeDMPeerId()) {
addDMTypingPeer(event.payload.peer_id);
}
break;
case "voice_participant_joined":
handleVoiceParticipantJoined(event.payload);
break;
case "voice_participant_left":
handleVoiceParticipantLeft(event.payload);
break;
case "voice_media_state_changed":
handleVoiceMediaStateChanged(event.payload);
break;
case "voice_sdp_received":
handleVoiceSdpReceived(event.payload);
break;
case "voice_ice_candidate_received":
handleVoiceIceCandidateReceived(event.payload);
break;
} }
} }
@ -269,23 +425,38 @@ const App: Component = () => {
tauri.sendTypingIndicator(channelId).catch(() => {}); tauri.sendTypingIndicator(channelId).catch(() => {});
} }
function handleSendDM(content: string) { async function handleSendDM(content: string) {
const peerId = activeDMPeerId(); const peerId = activeDMPeerId();
if (!peerId) return; if (!peerId) return;
const id = identity(); if (tauriAvailable()) {
const msg = { try {
id: `dm_${Date.now()}`, const msg = await tauri.sendDM(peerId, content);
channel_id: `dm_${peerId}`, addDMMessage(msg);
author_id: id?.peer_id ?? "local", updateDMLastMessage(peerId, content, msg.timestamp);
author_name: id?.display_name ?? "you", } catch (e) {
content, console.error("failed to send dm:", e);
timestamp: Date.now(), }
edited: false, } else {
}; // demo mode fallback
const id = identity();
const msg: DirectMessage = {
id: `dm_${Date.now()}`,
from_peer: id?.peer_id ?? "local",
to_peer: peerId,
from_display_name: id?.display_name ?? "you",
content,
timestamp: Date.now(),
};
addDMMessage(msg);
updateDMLastMessage(peerId, content, msg.timestamp);
}
}
addDMMessage(msg); function handleDMTyping() {
updateDMLastMessage(peerId, content, msg.timestamp); const peerId = activeDMPeerId();
if (!peerId || !tauriAvailable()) return;
tauri.sendDMTyping(peerId).catch(() => {});
} }
function handleOverlayNavigate(action: string) { function handleOverlayNavigate(action: string) {
@ -318,17 +489,8 @@ const App: Component = () => {
try { try {
const community = await tauri.createCommunity(name, desc); const community = await tauri.createCommunity(name, desc);
addCommunity(community); addCommunity(community);
// the createEffect on activeCommunityId handles loading channels, messages, members
setActiveCommunity(community.id); setActiveCommunity(community.id);
const channels = await tauri.getChannels(community.id);
setChannels(channels);
if (channels.length > 0) {
setActiveChannel(channels[0].id);
clearMessages();
}
const members = await tauri.getMembers(community.id);
setMembers(members);
} catch (e) { } catch (e) {
console.error("failed to create community:", e); console.error("failed to create community:", e);
} }
@ -350,6 +512,8 @@ const App: Component = () => {
name: "general", name: "general",
topic: "general discussion", topic: "general discussion",
kind: "Text", kind: "Text",
position: 0,
category_id: null,
}, },
]); ]);
setActiveChannel(chId); setActiveChannel(chId);
@ -379,17 +543,8 @@ const App: Component = () => {
try { try {
const community = await tauri.joinCommunity(inviteCode); const community = await tauri.joinCommunity(inviteCode);
addCommunity(community); addCommunity(community);
// the createEffect on activeCommunityId handles loading channels, messages, members
setActiveCommunity(community.id); setActiveCommunity(community.id);
const channels = await tauri.getChannels(community.id);
setChannels(channels);
if (channels.length > 0) {
setActiveChannel(channels[0].id);
clearMessages();
}
const members = await tauri.getMembers(community.id);
setMembers(members);
} catch (e) { } catch (e) {
console.error("failed to join community:", e); console.error("failed to join community:", e);
} }
@ -412,6 +567,8 @@ const App: Component = () => {
name: "general", name: "general",
topic: "general discussion", topic: "general discussion",
kind: "Text", kind: "Text",
position: 0,
category_id: null,
}, },
]); ]);
setActiveChannel(chId); setActiveChannel(chId);
@ -435,35 +592,78 @@ const App: Component = () => {
async function handleCreateChannel() { async function handleCreateChannel() {
const name = newChannelName().trim(); const name = newChannelName().trim();
const topic = newChannelTopic().trim(); const topic = newChannelTopic().trim();
const kind = newChannelKind();
const categoryId = newChannelCategoryId();
const communityId = activeCommunityId(); const communityId = activeCommunityId();
if (!name || !communityId) return; if (!name || !communityId) return;
if (tauriAvailable()) { if (tauriAvailable()) {
try { try {
const channel = await tauri.createChannel(communityId, name, topic); const channel = await tauri.createChannel(
communityId,
name,
topic,
kind.toLowerCase(),
categoryId,
);
setChannels((prev) => [...prev, channel]); setChannels((prev) => [...prev, channel]);
setActiveChannel(channel.id); // only auto-select text channels after creation
clearMessages(); if (channel.kind === "Text") {
setActiveChannel(channel.id);
}
} catch (e) { } catch (e) {
console.error("failed to create channel:", e); console.error("failed to create channel:", e);
} }
} else { } else {
// demo mode // demo mode
const chId = `ch_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}`; const chId = `ch_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}`;
const channel = { const channel: ChannelMeta = {
id: chId, id: chId,
community_id: communityId, community_id: communityId,
name, name,
topic: topic || `${name} discussion`, topic: topic || `${name} discussion`,
kind: "Text" as const, kind,
position: 0,
category_id: categoryId,
}; };
setChannels((prev) => [...prev, channel]); setChannels((prev) => [...prev, channel]);
setActiveChannel(chId); if (kind === "Text") {
clearMessages(); setActiveChannel(chId);
clearMessages();
}
} }
setNewChannelName(""); setNewChannelName("");
setNewChannelTopic(""); setNewChannelTopic("");
setNewChannelKind("Text");
setNewChannelCategoryId(null);
closeModal();
}
async function handleCreateCategory() {
const name = newCategoryName().trim();
const communityId = activeCommunityId();
if (!name || !communityId) return;
if (tauriAvailable()) {
try {
const category = await tauri.createCategory(communityId, name);
addCategory(category);
} catch (e) {
console.error("failed to create category:", e);
}
} else {
// demo mode
const catId = `cat_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}`;
addCategory({
id: catId,
community_id: communityId,
name,
position: 0,
});
}
setNewCategoryName("");
closeModal(); closeModal();
} }
@ -487,10 +687,18 @@ const App: Component = () => {
closeModal(); closeModal();
} }
async function handleSignUpComplete(displayName: string, bio: string) { async function handleSignUpComplete(
displayName: string,
bio: string,
challengeData?: ChallengeExport,
) {
if (tauriAvailable()) { if (tauriAvailable()) {
try { try {
const created = await tauri.createIdentity(displayName, bio); const created = await tauri.createIdentity(
displayName,
bio,
challengeData,
);
setCurrentIdentity(created); setCurrentIdentity(created);
updateSettings({ display_name: displayName }); updateSettings({ display_name: displayName });
@ -532,13 +740,16 @@ const App: Component = () => {
setCommunities([]); setCommunities([]);
setActiveCommunity(null); setActiveCommunity(null);
setChannels([]); setChannels([]);
setCategories([]);
setActiveChannel(null); setActiveChannel(null);
clearMessages(); clearMessages();
setMembers([]); setMembers([]);
setDMConversations([]); setDMConversations([]);
setActiveDM(null); setActiveDM(null);
clearDMTypingPeers();
setPeerCount(0); setPeerCount(0);
setIsConnected(false); setIsConnected(false);
setRelayConnected(true);
setNodeStatus("stopped"); setNodeStatus("stopped");
localStorage.removeItem("dusk_user_settings"); localStorage.removeItem("dusk_user_settings");
@ -553,6 +764,14 @@ const App: Component = () => {
return ( return (
<div class="h-screen w-screen overflow-hidden bg-black"> <div class="h-screen w-screen overflow-hidden bg-black">
<Show when={showSplash()}>
<SplashScreen
onComplete={() => setShowSplash(false)}
identity={identity()}
relayConnected={relayConnected()}
/>
</Show>
<Show when={needsSignUp()}> <Show when={needsSignUp()}>
<SignUpScreen onComplete={handleSignUpComplete} /> <SignUpScreen onComplete={handleSignUpComplete} />
</Show> </Show>
@ -563,8 +782,12 @@ const App: Component = () => {
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
onTyping={handleTyping} onTyping={handleTyping}
onSendDM={handleSendDM} onSendDM={handleSendDM}
onDMTyping={handleDMTyping}
/> />
<ProfileCard />
<ProfileModal />
<OverlayMenu <OverlayMenu
isOpen={overlayMenuOpen()} isOpen={overlayMenuOpen()}
onClose={closeOverlay} onClose={closeOverlay}
@ -647,6 +870,37 @@ const App: Component = () => {
title="create channel" title="create channel"
> >
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{/* channel type selector */}
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
type
</label>
<div class="flex gap-2">
<button
type="button"
class={`flex-1 px-4 py-3 text-[16px] border-2 transition-colors duration-200 cursor-pointer ${
newChannelKind() === "Text"
? "border-orange bg-orange/10 text-white"
: "border-white/20 bg-black text-white/60 hover:border-white/40"
}`}
onClick={() => setNewChannelKind("Text")}
>
text
</button>
<button
type="button"
class={`flex-1 px-4 py-3 text-[16px] border-2 transition-colors duration-200 cursor-pointer ${
newChannelKind() === "Voice"
? "border-orange bg-orange/10 text-white"
: "border-white/20 bg-black text-white/60 hover:border-white/40"
}`}
onClick={() => setNewChannelKind("Voice")}
>
voice
</button>
</div>
</div>
<div> <div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2"> <label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
name name
@ -671,6 +925,28 @@ const App: Component = () => {
onInput={(e) => setNewChannelTopic(e.currentTarget.value)} onInput={(e) => setNewChannelTopic(e.currentTarget.value)}
/> />
</div> </div>
{/* category selector */}
<Show when={categories().length > 0}>
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
category (optional)
</label>
<select
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none focus:border-orange transition-colors duration-200 cursor-pointer"
value={newChannelCategoryId() ?? ""}
onChange={(e) =>
setNewChannelCategoryId(e.currentTarget.value || null)
}
>
<option value="">no category</option>
<For each={categories()}>
{(cat) => <option value={cat.id}>{cat.name}</option>}
</For>
</select>
</div>
</Show>
<Button <Button
variant="primary" variant="primary"
fullWidth fullWidth
@ -682,6 +958,35 @@ const App: Component = () => {
</div> </div>
</Modal> </Modal>
<Modal
isOpen={activeModal() === "create-category"}
onClose={closeModal}
title="create category"
>
<div class="flex flex-col gap-4">
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
name
</label>
<input
type="text"
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
placeholder="category name"
value={newCategoryName()}
onInput={(e) => setNewCategoryName(e.currentTarget.value)}
/>
</div>
<Button
variant="primary"
fullWidth
onClick={handleCreateCategory}
disabled={!newCategoryName().trim()}
>
create
</Button>
</div>
</Modal>
<SettingsModal <SettingsModal
isOpen={activeModal() === "settings"} isOpen={activeModal() === "settings"}
onClose={closeModal} onClose={closeModal}
@ -736,6 +1041,8 @@ function loadDemoData() {
name: "general", name: "general",
topic: "general discussion about dusk development", topic: "general discussion about dusk development",
kind: "Text", kind: "Text",
position: 0,
category_id: null,
}, },
{ {
id: "ch_design_001", id: "ch_design_001",
@ -743,6 +1050,8 @@ function loadDemoData() {
name: "design", name: "design",
topic: "UI/UX design discussion", topic: "UI/UX design discussion",
kind: "Text", kind: "Text",
position: 1,
category_id: null,
}, },
{ {
id: "ch_voice_001", id: "ch_voice_001",
@ -750,6 +1059,8 @@ function loadDemoData() {
name: "voice", name: "voice",
topic: "", topic: "",
kind: "Voice", kind: "Voice",
position: 0,
category_id: null,
}, },
]); ]);
@ -885,7 +1196,6 @@ function loadDemoData() {
{ {
peer_id: "12D3KooWPeer_alice", peer_id: "12D3KooWPeer_alice",
display_name: "alice", display_name: "alice",
status: "Online",
last_message: "the gossipsub refactor is merged, check it out", last_message: "the gossipsub refactor is merged, check it out",
last_message_time: now - 600000, last_message_time: now - 600000,
unread_count: 2, unread_count: 2,
@ -893,7 +1203,6 @@ function loadDemoData() {
{ {
peer_id: "12D3KooWPeer_bob", peer_id: "12D3KooWPeer_bob",
display_name: "bob", display_name: "bob",
status: "Idle",
last_message: "sure, i'll review the PR tonight", last_message: "sure, i'll review the PR tonight",
last_message_time: now - 3600000, last_message_time: now - 3600000,
unread_count: 0, unread_count: 0,
@ -901,7 +1210,6 @@ function loadDemoData() {
{ {
peer_id: "12D3KooWPeer_charlie", peer_id: "12D3KooWPeer_charlie",
display_name: "charlie", display_name: "charlie",
status: "Online",
last_message: "NAT traversal test results look promising", last_message: "NAT traversal test results look promising",
last_message_time: now - 7200000, last_message_time: now - 7200000,
unread_count: 1, unread_count: 1,
@ -909,7 +1217,6 @@ function loadDemoData() {
{ {
peer_id: "12D3KooWPeer_diana", peer_id: "12D3KooWPeer_diana",
display_name: "diana", display_name: "diana",
status: "Offline",
last_message: "offline, will catch up tomorrow", last_message: "offline, will catch up tomorrow",
last_message_time: now - 86400000, last_message_time: now - 86400000,
unread_count: 0, unread_count: 0,

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 128 128">
<rect width="128" height="128" fill="#000000"/>
<mask id="m">
<circle cx="64" cy="64" r="40" fill="white"/>
<circle cx="48" cy="54" r="32" fill="black"/>
</mask>
<circle cx="64" cy="64" r="40" fill="#FF4F00" mask="url(#m)"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

View File

@ -0,0 +1,539 @@
import type { Component } from "solid-js";
import { createSignal, Show, For, onCleanup } from "solid-js";
import { Shield, Check } from "lucide-solid";
import Button from "../common/Button";
import type { ChallengeExport } from "../../lib/types";
interface HumanVerificationProps {
onVerified: (data: ChallengeExport) => void;
}
// -- data structures for mouse tracking and analysis --
interface TargetCircle {
id: number;
x: number;
y: number;
}
interface MouseSample {
x: number;
y: number;
t: number;
}
interface SegmentData {
fromTarget: number;
toTarget: number;
samples: MouseSample[];
clickTime: number;
startTime: number;
}
interface ChallengeData {
segments: SegmentData[];
totalStartTime: number;
totalEndTime: number;
}
type Phase = "ready" | "active" | "analyzing" | "passed" | "failed";
// -- constants --
const CONTAINER_WIDTH = 600;
const CONTAINER_HEIGHT = 400;
const CIRCLE_RADIUS = 24;
const MIN_DISTANCE = 120;
const PADDING = 48;
const TARGET_COUNT = 5;
const HUMAN_THRESHOLD = 0.35;
// -- circle positioning --
function generateCirclePositions(
width: number,
height: number,
): TargetCircle[] {
const circles: TargetCircle[] = [];
const maxAttempts = 200;
for (let id = 1; id <= TARGET_COUNT; id++) {
let placed = false;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const x = PADDING + Math.random() * (width - 2 * PADDING);
const y = PADDING + Math.random() * (height - 2 * PADDING);
const tooClose = circles.some((c) => {
const dx = c.x - x;
const dy = c.y - y;
return Math.sqrt(dx * dx + dy * dy) < MIN_DISTANCE;
});
if (!tooClose) {
circles.push({ id, x, y });
placed = true;
break;
}
}
// fallback grid placement if rejection sampling exhausts attempts
if (!placed) {
const cols = 3;
const row = Math.floor((id - 1) / cols);
const col = (id - 1) % cols;
circles.push({
id,
x: PADDING + col * ((width - 2 * PADDING) / (cols - 1)),
y: PADDING + row * ((height - 2 * PADDING) / 1),
});
}
}
return circles;
}
// -- analysis functions --
// each returns a score from 0.0 (bot-like) to 1.0 (human-like)
function scoreTimingVariance(segments: SegmentData[]): number {
if (segments.length < 2) return 0;
const intervals = segments.map((s) => s.clickTime - s.startTime);
const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length;
if (mean === 0) return 0;
const variance =
intervals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / intervals.length;
const cv = Math.sqrt(variance) / mean;
// humans have natural variance in click timing
// bots tend to be metronomic or instantaneous
if (cv < 0.03) return 0;
if (cv < 0.08) return 0.3;
if (cv < 0.12) return 0.6;
return 1.0;
}
function scorePathCurvature(segments: SegmentData[]): number {
const ratios: number[] = [];
for (const seg of segments) {
if (seg.samples.length < 3) continue;
const first = seg.samples[0];
const last = seg.samples[seg.samples.length - 1];
const straightDist = Math.sqrt(
(last.x - first.x) ** 2 + (last.y - first.y) ** 2,
);
// skip very short movements where curvature is meaningless
if (straightDist < 10) continue;
let pathLength = 0;
for (let i = 1; i < seg.samples.length; i++) {
const dx = seg.samples[i].x - seg.samples[i - 1].x;
const dy = seg.samples[i].y - seg.samples[i - 1].y;
pathLength += Math.sqrt(dx * dx + dy * dy);
}
ratios.push(pathLength / straightDist);
}
if (ratios.length === 0) return 0;
const avgRatio = ratios.reduce((a, b) => a + b, 0) / ratios.length;
// humans never move in perfectly straight lines
// motor control imprecision guarantees some curvature
if (avgRatio < 1.02) return 0;
if (avgRatio < 1.06) return 0.3;
if (avgRatio < 1.1) return 0.6;
if (avgRatio > 4.0) return 0.5;
return 1.0;
}
function scoreSpeedVariance(segments: SegmentData[]): number {
const allSpeedCVs: number[] = [];
for (const seg of segments) {
if (seg.samples.length < 5) continue;
const speeds: number[] = [];
for (let i = 1; i < seg.samples.length; i++) {
const dx = seg.samples[i].x - seg.samples[i - 1].x;
const dy = seg.samples[i].y - seg.samples[i - 1].y;
const dt = seg.samples[i].t - seg.samples[i - 1].t;
if (dt > 0) {
speeds.push(Math.sqrt(dx * dx + dy * dy) / dt);
}
}
if (speeds.length < 3) continue;
const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
if (mean === 0) continue;
const variance =
speeds.reduce((sum, v) => sum + (v - mean) ** 2, 0) / speeds.length;
const cv = Math.sqrt(variance) / mean;
allSpeedCVs.push(cv);
}
if (allSpeedCVs.length === 0) return 0;
const avgCV = allSpeedCVs.reduce((a, b) => a + b, 0) / allSpeedCVs.length;
// humans accelerate and decelerate naturally along paths
// bots maintain constant velocity
if (avgCV < 0.1) return 0;
if (avgCV < 0.25) return 0.4;
if (avgCV < 0.4) return 0.7;
return 1.0;
}
function scoreApproachJitter(
segments: SegmentData[],
circles: TargetCircle[],
): number {
const jitterScores: number[] = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const target = circles[i];
if (seg.samples.length < 5) continue;
// isolate the last stretch of movement approaching the target
const approachSamples = seg.samples.filter((s) => {
const dx = s.x - target.x;
const dy = s.y - target.y;
return Math.sqrt(dx * dx + dy * dy) < 60;
});
if (approachSamples.length < 4) continue;
// count direction changes via cross product sign flips
let directionChanges = 0;
for (let j = 2; j < approachSamples.length; j++) {
const dx1 = approachSamples[j - 1].x - approachSamples[j - 2].x;
const dy1 = approachSamples[j - 1].y - approachSamples[j - 2].y;
const dx2 = approachSamples[j].x - approachSamples[j - 1].x;
const dy2 = approachSamples[j].y - approachSamples[j - 1].y;
const cross = dx1 * dy2 - dy1 * dx2;
if (j > 2) {
const prevDx1 = approachSamples[j - 2].x - approachSamples[j - 3].x;
const prevDy1 = approachSamples[j - 2].y - approachSamples[j - 3].y;
const prevCross = prevDx1 * dy1 - prevDy1 * dx1;
if (cross * prevCross < 0) directionChanges++;
}
}
const jitterRatio =
directionChanges / Math.max(approachSamples.length - 2, 1);
jitterScores.push(jitterRatio);
}
// not enough data to judge, give a neutral score
if (jitterScores.length === 0) return 0.5;
const avgJitter =
jitterScores.reduce((a, b) => a + b, 0) / jitterScores.length;
// humans have micro-corrections from motor noise (fitts's law)
// bots converge smoothly with zero directional jitter
if (avgJitter < 0.01) return 0.2;
if (avgJitter < 0.05) return 0.5;
return 1.0;
}
function scoreOverallTiming(data: ChallengeData): number {
const totalMs = data.totalEndTime - data.totalStartTime;
const totalSec = totalMs / 1000;
if (totalSec < 0.8) return 0;
if (totalSec < 1.5) return 0.3;
if (totalSec > 60) return 0.5;
return 1.0;
}
function analyzeChallenge(
data: ChallengeData,
circles: TargetCircle[],
): { isHuman: boolean; score: number } {
const timing = scoreTimingVariance(data.segments);
const curvature = scorePathCurvature(data.segments);
const speed = scoreSpeedVariance(data.segments);
const jitter = scoreApproachJitter(data.segments, circles);
const overall = scoreOverallTiming(data);
const score =
timing * 0.25 +
curvature * 0.25 +
speed * 0.2 +
jitter * 0.2 +
overall * 0.1;
return {
isHuman: score >= HUMAN_THRESHOLD,
score,
};
}
// -- component --
const HumanVerification: Component<HumanVerificationProps> = (props) => {
let containerRef: HTMLDivElement | undefined;
const [phase, setPhase] = createSignal<Phase>("ready");
const [circles, setCircles] = createSignal<TargetCircle[]>(
generateCirclePositions(CONTAINER_WIDTH, CONTAINER_HEIGHT),
);
const [currentTarget, setCurrentTarget] = createSignal(1);
const [completedCount, setCompletedCount] = createSignal(0);
const [wrongClickId, setWrongClickId] = createSignal<number | null>(null);
const [failureMessage, setFailureMessage] = createSignal("");
// mutable tracking state, no reactivity needed
let challengeData: ChallengeData = {
segments: [],
totalStartTime: 0,
totalEndTime: 0,
};
let currentSegmentSamples: MouseSample[] = [];
let currentSegmentStartTime = 0;
let wrongClickTimeout: ReturnType<typeof setTimeout> | undefined;
onCleanup(() => {
if (wrongClickTimeout) clearTimeout(wrongClickTimeout);
});
function handleStart() {
const now = performance.now();
challengeData = {
segments: [],
totalStartTime: now,
totalEndTime: 0,
};
currentSegmentSamples = [];
currentSegmentStartTime = now;
setPhase("active");
}
function handleMouseMove(e: MouseEvent) {
if (phase() !== "active") return;
const rect = containerRef!.getBoundingClientRect();
currentSegmentSamples.push({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
t: performance.now(),
});
}
function handleCircleClick(circleId: number, e: MouseEvent) {
// prevent click events during non-active phases
if (phase() !== "active") return;
e.stopPropagation();
if (circleId !== currentTarget()) {
// wrong target feedback
setWrongClickId(circleId);
if (wrongClickTimeout) clearTimeout(wrongClickTimeout);
wrongClickTimeout = setTimeout(() => setWrongClickId(null), 400);
return;
}
// correct target hit
const now = performance.now();
const rect = containerRef!.getBoundingClientRect();
challengeData.segments.push({
fromTarget: circleId - 1,
toTarget: circleId,
samples: [...currentSegmentSamples],
clickTime: now,
startTime: currentSegmentStartTime,
});
// reset for next segment
currentSegmentSamples = [
{ x: e.clientX - rect.left, y: e.clientY - rect.top, t: now },
];
currentSegmentStartTime = now;
const nextCount = completedCount() + 1;
setCompletedCount(nextCount);
if (nextCount >= TARGET_COUNT) {
// all targets hit, begin analysis
challengeData.totalEndTime = now;
setPhase("analyzing");
setTimeout(() => {
const result = analyzeChallenge(challengeData, circles());
if (result.isHuman) {
setPhase("passed");
// package raw challenge data for the backend to re-validate
const exportData: ChallengeExport = {
segments: challengeData.segments.map((s) => ({
fromTarget: s.fromTarget,
toTarget: s.toTarget,
samples: s.samples.map((m) => ({ x: m.x, y: m.y, t: m.t })),
clickTime: s.clickTime,
startTime: s.startTime,
})),
circles: circles().map((c) => ({ id: c.id, x: c.x, y: c.y })),
totalStartTime: challengeData.totalStartTime,
totalEndTime: challengeData.totalEndTime,
};
setTimeout(() => props.onVerified(exportData), 600);
} else {
setFailureMessage(
"verification failed. please try again.",
);
setPhase("failed");
}
}, 1500);
} else {
setCurrentTarget(circleId + 1);
}
}
function handleRetry() {
setCircles(generateCirclePositions(CONTAINER_WIDTH, CONTAINER_HEIGHT));
setCurrentTarget(1);
setCompletedCount(0);
setWrongClickId(null);
setFailureMessage("");
challengeData = {
segments: [],
totalStartTime: 0,
totalEndTime: 0,
};
currentSegmentSamples = [];
currentSegmentStartTime = 0;
setPhase("ready");
}
function circleClasses(circleId: number): string {
const base =
"absolute flex items-center justify-center rounded-full w-12 h-12 text-[16px] font-bold cursor-pointer transition-colors duration-200 select-none";
if (wrongClickId() === circleId) {
return `${base} border-2 border-error text-white bg-orange-muted animate-target-shake`;
}
if (circleId < currentTarget()) {
// completed
return `${base} border-2 border-white/10 text-white/15 animate-target-complete pointer-events-none`;
}
if (circleId === currentTarget()) {
// active target
return `${base} border-2 border-orange text-white bg-orange-muted animate-target-pulse`;
}
// pending
return `${base} border-2 border-white/20 text-white/30`;
}
return (
<div class="max-w-[680px] w-full mx-4 animate-fade-in">
<div class="mb-8 px-10">
<div class="flex items-center gap-3 mb-4">
<Shield size={24} class="text-orange" />
<h2 class="text-[32px] leading-[40px] font-bold text-white tracking-[-0.02em]">
human verfication
</h2>
</div>
<p>complete the action below to verify your humanity</p>
</div>
{/* challenge area */}
<div
ref={containerRef}
class="relative border-2 border-white/10 bg-black mx-auto"
style={{
width: `${CONTAINER_WIDTH}px`,
height: `${CONTAINER_HEIGHT}px`,
}}
onMouseMove={handleMouseMove}
>
{/* ready state overlay */}
<Show when={phase() === "ready"}>
<div class="absolute inset-0 flex flex-col items-center justify-center z-10 bg-black/80 backdrop-blur-sm">
<p class="text-[14px] font-mono text-white/30 mb-6">
click the circles in order from 1 to 5.
</p>
<Button variant="primary" onClick={handleStart}>
begin
</Button>
</div>
</Show>
{/* target circles, visible during ready (dimmed behind overlay) and active */}
<Show
when={
phase() !== "analyzing" &&
phase() !== "passed" &&
phase() !== "failed"
}
>
<For each={circles()}>
{(circle, index) => (
<div
class={circleClasses(circle.id)}
style={{
left: `${circle.x - CIRCLE_RADIUS}px`,
top: `${circle.y - CIRCLE_RADIUS}px`,
"animation-delay":
phase() === "ready" ? `${index() * 80}ms` : undefined,
}}
onClick={(e) => handleCircleClick(circle.id, e)}
>
{circle.id}
</div>
)}
</For>
</Show>
{/* analyzing overlay */}
<Show when={phase() === "analyzing"}>
<div class="absolute inset-0 flex items-center justify-center animate-fade-in">
<p class="text-[16px] font-mono text-white/60">verifying...</p>
</div>
</Show>
{/* passed overlay */}
<Show when={phase() === "passed"}>
<div class="absolute inset-0 flex flex-col items-center justify-center animate-scale-in">
<div class="w-16 h-16 rounded-full border-2 border-success flex items-center justify-center mb-4">
<Check size={32} class="text-success" />
</div>
<p class="text-[16px] font-mono text-success">verified</p>
</div>
</Show>
</div>
{/* progress indicator */}
<Show when={phase() === "active"}>
<p class="text-[14px] font-mono text-white/40 mt-4 text-center">
{completedCount()} / {TARGET_COUNT}
</p>
</Show>
{/* failure state */}
<Show when={phase() === "failed"}>
<div class="mt-6 text-center">
<p class="text-[14px] text-error mb-4">{failureMessage()}</p>
<Button variant="secondary" onClick={handleRetry}>
try again
</Button>
</div>
</Show>
</div>
);
};
export default HumanVerification;

View File

@ -1,28 +1,41 @@
import { Component, createSignal, Show } from "solid-js"; import { Component, createSignal, Show, Switch, Match } from "solid-js";
import { Key, User, ArrowRight, Shield } from "lucide-solid"; import { ArrowRight } from "lucide-solid";
import Button from "../common/Button"; import Button from "../common/Button";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import HumanVerification from "./HumanVerification";
import type { ChallengeExport } from "../../lib/types";
interface SignUpScreenProps { interface SignUpScreenProps {
onComplete: (displayName: string, bio: string) => void; onComplete: (
displayName: string,
bio: string,
challengeData: ChallengeExport,
) => void;
} }
const SignUpScreen: Component<SignUpScreenProps> = (props) => { const SignUpScreen: Component<SignUpScreenProps> = (props) => {
const [displayName, setDisplayName] = createSignal(""); const [displayName, setDisplayName] = createSignal("");
const [bio, setBio] = createSignal(""); const [bio, setBio] = createSignal("");
const [step, setStep] = createSignal<"welcome" | "profile">("welcome"); const [step, setStep] = createSignal<"welcome" | "verification" | "profile">(
"welcome",
);
const [isCreating, setIsCreating] = createSignal(false); const [isCreating, setIsCreating] = createSignal(false);
const [challengeData, setChallengeData] =
createSignal<ChallengeExport | null>(null);
function handleBegin() { function handleBegin() {
setStep("profile"); setStep("verification");
} }
async function handleCreate() { async function handleCreate() {
const name = displayName().trim(); const name = displayName().trim();
if (!name) return; if (!name) return;
const challenge = challengeData();
if (!challenge) return;
setIsCreating(true); setIsCreating(true);
props.onComplete(name, bio().trim()); props.onComplete(name, bio().trim(), challenge);
} }
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
@ -30,9 +43,10 @@ const SignUpScreen: Component<SignUpScreenProps> = (props) => {
e.preventDefault(); e.preventDefault();
if (step() === "welcome") { if (step() === "welcome") {
handleBegin(); handleBegin();
} else if (displayName().trim()) { } else if (step() === "profile" && displayName().trim()) {
handleCreate(); handleCreate();
} }
// verification step is mouse-only, no keyboard shortcuts
} }
} }
@ -41,68 +55,19 @@ const SignUpScreen: Component<SignUpScreenProps> = (props) => {
class="h-screen w-screen bg-black flex items-center justify-center overflow-hidden" class="h-screen w-screen bg-black flex items-center justify-center overflow-hidden"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<Show <Switch>
when={step() === "profile"} <Match when={step() === "welcome"}>
fallback={ {/* welcome screen */}
// welcome screen
<div class="max-w-[520px] w-full mx-4 animate-fade-in"> <div class="max-w-[520px] w-full mx-4 animate-fade-in">
<div class="mb-12"> <div class="mb-12">
<h1 class="text-[48px] leading-[56px] font-bold text-white tracking-[-0.02em] mb-4"> <h1 class="text-[48px] leading-[56px] font-bold text-white tracking-[-0.02em] mb-4">
dusk welcome to dusk chat
</h1> </h1>
<p class="text-[20px] leading-[28px] text-white/60"> <p class="text-[20px] leading-[28px] text-white/60">
peer-to-peer communication. no servers, no surveillance, no truly private peer-to-peer messaging for the masses.
compromise.
</p> </p>
</div> </div>
<div class="flex flex-col gap-6 mb-12">
<div class="flex items-start gap-4">
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
<Key size={18} class="text-orange" />
</div>
<div>
<p class="text-[16px] font-medium text-white mb-1">
keypair identity
</p>
<p class="text-[14px] text-white/40">
your identity is a cryptographic keypair generated on your
device. no email, no phone number, no corporate account.
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
<Shield size={18} class="text-orange" />
</div>
<div>
<p class="text-[16px] font-medium text-white mb-1">
your data, your hardware
</p>
<p class="text-[14px] text-white/40">
everything is stored locally and synced directly between
peers. no central server ever touches your messages.
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
<User size={18} class="text-orange" />
</div>
<div>
<p class="text-[16px] font-medium text-white mb-1">
portable identity
</p>
<p class="text-[14px] text-white/40">
take your identity anywhere. your keypair is yours forever
and works across any device running dusk.
</p>
</div>
</div>
</div>
<Button variant="primary" fullWidth onClick={handleBegin}> <Button variant="primary" fullWidth onClick={handleBegin}>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
get started get started
@ -110,97 +75,110 @@ const SignUpScreen: Component<SignUpScreenProps> = (props) => {
</span> </span>
</Button> </Button>
</div> </div>
} </Match>
>
{/* profile creation screen */}
<div class="max-w-[480px] w-full mx-4 animate-fade-in">
<h2 class="text-[32px] leading-[40px] font-bold text-white tracking-[-0.02em] mb-2">
create your identity
</h2>
<p class="text-[16px] text-white/40 mb-8">
choose a display name for the network. you can change this later.
</p>
{/* live preview */} <Match when={step() === "verification"}>
<div class="flex items-center gap-4 p-4 border-2 border-white/10 mb-8"> <HumanVerification
<Avatar onVerified={(data) => {
name={displayName() || "?"} setChallengeData(data);
size="xl" setStep("profile");
status="Online" }}
showStatus />
/> </Match>
<div class="min-w-0 flex-1">
<p class="text-[20px] font-bold text-white truncate">
{displayName() || "your name"}
</p>
<Show when={bio()}>
<p class="text-[14px] text-white/40 truncate mt-1">{bio()}</p>
</Show>
<p class="text-[12px] font-mono text-white/20 mt-1">
peer id will be generated
</p>
</div>
</div>
<div class="flex flex-col gap-4 mb-8"> <Match when={step() === "profile"}>
<div> {/* profile creation screen */}
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2"> <div class="max-w-[480px] w-full mx-4 animate-fade-in">
display name * <h2 class="text-[32px] leading-[40px] font-bold text-white tracking-[-0.02em] mb-2">
</label> create your identity
<input </h2>
type="text" <p class="text-[16px] text-white/40 mb-8">
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200" choose a display name for the network. you can change this later.
placeholder="what should people call you?" </p>
value={displayName()}
onInput={(e) => setDisplayName(e.currentTarget.value)} {/* live preview */}
maxLength={32} <div class="flex items-center gap-4 p-4 border-2 border-white/10 mb-8">
autofocus <Avatar
name={displayName() || "?"}
size="xl"
status="Online"
showStatus
/> />
<p class="text-[12px] font-mono text-white/20 mt-1"> <div class="min-w-0 flex-1">
{displayName().length}/32 <p class="text-[20px] font-bold text-white truncate">
</p> {displayName() || "your name"}
</p>
<Show when={bio()}>
<p class="text-[14px] text-white/40 truncate mt-1">{bio()}</p>
</Show>
<p class="text-[12px] font-mono text-white/20 mt-1">
peer id will be generated
</p>
</div>
</div> </div>
<div> <div class="flex flex-col gap-4 mb-8">
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2"> <div>
bio (optional) <label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
</label> display name *
<textarea </label>
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200 resize-none" <input
placeholder="tell peers a bit about yourself" type="text"
value={bio()} class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
onInput={(e) => setBio(e.currentTarget.value)} placeholder="what should people call you?"
maxLength={160} value={displayName()}
rows={3} onInput={(e) => setDisplayName(e.currentTarget.value)}
/> maxLength={32}
<p class="text-[12px] font-mono text-white/20 mt-1"> autofocus
{bio().length}/160 />
</p> <p class="text-[12px] font-mono text-white/20 mt-1">
{displayName().length}/32
</p>
</div>
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
bio (optional)
</label>
<textarea
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200 resize-none"
placeholder="tell peers a bit about yourself"
value={bio()}
onInput={(e) => setBio(e.currentTarget.value)}
maxLength={160}
rows={3}
/>
<p class="text-[12px] font-mono text-white/20 mt-1">
{bio().length}/160
</p>
</div>
</div> </div>
<Button
variant="primary"
fullWidth
onClick={handleCreate}
disabled={!displayName().trim() || isCreating()}
>
{isCreating() ? "generating keypair..." : "create identity"}
</Button>
<p class="text-[12px] font-mono text-white/20 text-center mt-4">
an ed25519 keypair will be generated and stored locally on your
device
</p>
</div> </div>
</Match>
</Switch>
<Button {/* dev mode indicator*/}
variant="primary" <Show when={"__TAURI_INTERNALS__" in window}>
fullWidth <div class="fixed bottom-6 left-0 right-0 text-center">
onClick={handleCreate} <p class="text-[11px] font-mono text-white/10 uppercase tracking-[0.1em]">
disabled={!displayName().trim() || isCreating()} development version
>
{isCreating() ? "generating keypair..." : "create identity"}
</Button>
<p class="text-[12px] font-mono text-white/20 text-center mt-4">
an ed25519 keypair will be generated and stored locally on your
device
</p> </p>
</div> </div>
</Show> </Show>
{/* subtle branding at bottom */}
<div class="fixed bottom-6 left-0 right-0 text-center">
<p class="text-[11px] font-mono text-white/10 uppercase tracking-[0.1em]">
dusk protocol v0.1.0
</p>
</div>
</div> </div>
); );
}; };

View File

@ -0,0 +1,234 @@
import {
Component,
createSignal,
createEffect,
onMount,
Show,
onCleanup,
} from "solid-js";
import type { PublicIdentity } from "../../lib/types";
import { checkInternetConnectivity } from "../../lib/tauri";
interface SplashScreenProps {
onComplete: () => void;
identity: PublicIdentity | null;
relayConnected: boolean;
}
const SplashScreen: Component<SplashScreenProps> = (props) => {
const [fading, setFading] = createSignal(false);
const [animationComplete, setAnimationComplete] = createSignal(false);
const [showWelcome, setShowWelcome] = createSignal(false);
const [svgMounted, setSvgMounted] = createSignal(true);
const [retrying, setRetrying] = createSignal(false);
// null = not checked yet, true = internet works, false = no internet
const [hasInternet, setHasInternet] = createSignal<boolean | null>(null);
// refs for exit SMIL animations so we can trigger them programmatically
let exitOrangeCx: SVGAnimateElement | undefined;
let exitOrangeOpacity: SVGAnimateElement | undefined;
let exitBlackCx: SVGAnimateElement | undefined;
let exitBlackOpacity: SVGAnimateElement | undefined;
let animTimer: ReturnType<typeof setTimeout>;
let exitTimer: ReturnType<typeof setTimeout>;
let loopTimer: ReturnType<typeof setTimeout>;
function clearTimers() {
clearTimeout(animTimer);
clearTimeout(exitTimer);
clearTimeout(loopTimer);
}
function startCycle() {
clearTimers();
animTimer = setTimeout(() => setAnimationComplete(true), 3450);
// if no connection after 5s, play exit and loop
exitTimer = setTimeout(() => {
if (props.relayConnected) return;
setRetrying(true);
// probe well-known hosts to determine if the issue is local or relay-side
checkInternetConnectivity()
.then((connected) => setHasInternet(connected))
.catch(() => setHasInternet(false));
// trigger reverse SMIL animations
exitOrangeCx?.beginElement();
exitOrangeOpacity?.beginElement();
exitBlackCx?.beginElement();
exitBlackOpacity?.beginElement();
// after exit finishes, unmount svg briefly to reset all animations
loopTimer = setTimeout(() => {
setAnimationComplete(false);
setSvgMounted(false);
requestAnimationFrame(() => {
setSvgMounted(true);
startCycle();
});
}, 700);
}, 5000);
}
onMount(() => startCycle());
// handle successful relay connection
createEffect(() => {
if (animationComplete() && props.relayConnected && props.identity) {
clearTimers();
setRetrying(false);
setShowWelcome(true);
setTimeout(() => setFading(true), 1200);
setTimeout(() => props.onComplete(), 1700);
}
});
onCleanup(() => clearTimers());
// derived status text to keep jsx clean
const statusText = () => {
if (showWelcome()) return null;
if (retrying()) return "retrying connection...";
if (animationComplete()) return "connecting...";
return "loading dusk...";
};
// secondary diagnostic message shown when retrying
const connectivityHint = () => {
if (!retrying() || hasInternet() === null) return null;
if (hasInternet()) {
return "your connection is working, we can't reach the relay server right now";
}
return "your internet connection is down. please troubleshoot your connection";
};
return (
<div
class="fixed inset-0 z-[9999] bg-black flex items-center justify-center"
classList={{ "splash-fadeout": fading() }}
>
<div class="flex flex-col items-center gap-8">
<Show when={svgMounted()}>
<svg
width="160"
height="160"
viewBox="0 0 128 128"
xmlns="http://www.w3.org/2000/svg"
>
{/* orange pops in at 2s (mostly visible on right), then slides left (80px travel) */}
<circle
cx="144"
cy="64"
r="40"
fill="#FF4F00"
class="splash-orange"
>
{/* entry: slide into crescent position */}
<animate
attributeName="cx"
from="144"
to="64"
dur="300ms"
begin="2.6s"
fill="freeze"
calcMode="spline"
keySplines="0.22, 1, 0.36, 1"
/>
{/* exit: slide back off-screen right */}
<animate
ref={exitOrangeCx}
attributeName="cx"
from="64"
to="200"
dur="500ms"
begin="indefinite"
fill="freeze"
calcMode="spline"
keySplines="0.33, 0, 0.67, 1"
/>
{/* exit: fade out */}
<animate
ref={exitOrangeOpacity}
attributeName="opacity"
from="1"
to="0"
dur="400ms"
begin="indefinite"
fill="freeze"
/>
</circle>
{/* black slides in from the left (80px travel) to carve the crescent */}
<circle cx="-30" cy="52.3" r="33" fill="#000000" opacity="0">
{/* entry: slide into mask position */}
<animate
attributeName="cx"
from="-32"
to="48"
dur="300ms"
begin="2.6s"
fill="freeze"
calcMode="paced"
keySplines="0.22, 1, 0.36, 1"
/>
<animate
attributeName="opacity"
from="0"
to="1"
dur="50ms"
begin="2.6s"
fill="freeze"
/>
{/* exit: slide back off-screen left */}
<animate
ref={exitBlackCx}
attributeName="cx"
from="48"
to="-60"
dur="500ms"
begin="indefinite"
fill="freeze"
calcMode="spline"
keySplines="0.33, 0, 0.67, 1"
/>
{/* exit: fade out */}
<animate
ref={exitBlackOpacity}
attributeName="opacity"
from="1"
to="0"
dur="400ms"
begin="indefinite"
fill="freeze"
/>
</circle>
</svg>
</Show>
<Show when={statusText()}>
<p
class="text-white/60 text-sm font-sans"
classList={{ "animate-pulse": !showWelcome() }}
>
{statusText()}
</p>
</Show>
<Show when={connectivityHint()}>
<p class="text-white/40 text-xs font-sans max-w-xs text-center">
{connectivityHint()}
</p>
</Show>
<Show when={showWelcome() && props.identity}>
<p class="text-white/60 text-sm font-sans">
connected to dusk chat, welcome {props.identity?.display_name}!
</p>
</Show>
</div>
</div>
);
};
export default SplashScreen;

View File

@ -6,6 +6,7 @@ import { removeMessage } from "../../stores/messages";
import { activeCommunityId } from "../../stores/communities"; import { activeCommunityId } from "../../stores/communities";
import { identity } from "../../stores/identity"; import { identity } from "../../stores/identity";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import { openProfileCard } from "../../stores/ui";
import * as tauri from "../../lib/tauri"; import * as tauri from "../../lib/tauri";
interface MessageProps { interface MessageProps {
@ -15,7 +16,10 @@ interface MessageProps {
} }
const Message: Component<MessageProps> = (props) => { const Message: Component<MessageProps> = (props) => {
const [contextMenu, setContextMenu] = createSignal<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = createSignal<{
x: number;
y: number;
} | null>(null);
const currentUser = () => identity(); const currentUser = () => identity();
const currentCommunityId = () => activeCommunityId(); const currentCommunityId = () => activeCommunityId();
@ -48,6 +52,16 @@ const Message: Component<MessageProps> = (props) => {
closeContextMenu(); closeContextMenu();
} }
function handleProfileClick(e: MouseEvent) {
e.stopPropagation();
openProfileCard({
peerId: props.message.author_id,
displayName: props.message.author_name,
anchorX: e.clientX,
anchorY: e.clientY,
});
}
// close context menu on click outside // close context menu on click outside
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.addEventListener("click", closeContextMenu); window.addEventListener("click", closeContextMenu);
@ -70,17 +84,25 @@ const Message: Component<MessageProps> = (props) => {
</div> </div>
} }
> >
<div class="w-10 shrink-0 pt-0.5"> <button
type="button"
class="w-10 shrink-0 pt-0.5 cursor-pointer"
onClick={handleProfileClick}
>
<Avatar name={props.message.author_name} size="md" /> <Avatar name={props.message.author_name} size="md" />
</div> </button>
</Show> </Show>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Show when={props.isFirstInGroup}> <Show when={props.isFirstInGroup}>
<div class="flex items-baseline gap-2 mb-0.5"> <div class="flex items-baseline gap-2 mb-0.5">
<span class="text-[16px] font-medium text-white"> <button
type="button"
class="text-[16px] font-medium text-white hover:text-orange transition-colors duration-200 cursor-pointer"
onClick={handleProfileClick}
>
{props.message.author_name} {props.message.author_name}
</span> </button>
<span class="text-[12px] font-mono text-white/50"> <span class="text-[12px] font-mono text-white/50">
{formatTime(props.message.timestamp)} {formatTime(props.message.timestamp)}
</span> </span>

View File

@ -35,7 +35,7 @@ const MessageList: Component<MessageListProps> = (props) => {
// auto-scroll when new messages arrive if user is at the bottom // auto-scroll when new messages arrive if user is at the bottom
createEffect(() => { createEffect(() => {
const _ = props.messages.length; void props.messages.length;
if (isAtBottom()) { if (isAtBottom()) {
// defer to allow dom update // defer to allow dom update
requestAnimationFrame(() => scrollToBottom(true)); requestAnimationFrame(() => scrollToBottom(true));

View File

@ -0,0 +1,319 @@
import type { Component } from "solid-js";
import { Show, createMemo, createEffect, onCleanup } from "solid-js";
import { Portal } from "solid-js/web";
import { UserPlus, UserMinus, Copy, Check } from "lucide-solid";
import { createSignal } from "solid-js";
import Avatar from "./Avatar";
import {
profileCardTarget,
closeProfileCard,
openProfileModal,
} from "../../stores/ui";
import { members } from "../../stores/members";
import { knownPeers } from "../../stores/directory";
import { identity } from "../../stores/identity";
import { markAsFriend, unmarkAsFriend } from "../../stores/directory";
import * as tauri from "../../lib/tauri";
import { formatTime } from "../../lib/utils";
const ProfileCard: Component = () => {
const [copied, setCopied] = createSignal(false);
let cardRef: HTMLDivElement | undefined;
const target = () => profileCardTarget();
const isOpen = () => target() !== null;
const isSelf = () => target()?.peerId === identity()?.peer_id;
// pull rich info from member list or directory
const memberInfo = createMemo(() => {
const t = target();
if (!t) return null;
return members().find((m) => m.peer_id === t.peerId) ?? null;
});
const directoryInfo = createMemo(() => {
const t = target();
if (!t) return null;
return knownPeers().find((p) => p.peer_id === t.peerId) ?? null;
});
const displayName = () =>
memberInfo()?.display_name ??
directoryInfo()?.display_name ??
target()?.displayName ??
"Unknown";
const bio = () =>
directoryInfo()?.bio || (isSelf() ? identity()?.bio : "") || "";
const isFriend = () => directoryInfo()?.is_friend ?? false;
const status = () => memberInfo()?.status ?? "Offline";
const roles = () => memberInfo()?.roles ?? [];
const joinedAt = () => memberInfo()?.joined_at ?? 0;
// close on escape or click outside
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") closeProfileCard();
}
function handleClickOutside(e: MouseEvent) {
if (cardRef && !cardRef.contains(e.target as Node)) {
closeProfileCard();
}
}
createEffect(() => {
if (isOpen()) {
// delay listener registration to avoid the triggering click from closing it
requestAnimationFrame(() => {
document.addEventListener("mousedown", handleClickOutside);
});
document.addEventListener("keydown", handleKeydown);
}
});
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeydown);
});
// close and re-register listeners whenever the target changes
createEffect(() => {
if (!isOpen()) {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeydown);
}
});
// compute position to stay within viewport
const cardPosition = createMemo(() => {
const t = target();
if (!t) return { top: 0, left: 0 };
const cardWidth = 320;
const cardHeight = 340;
const margin = 12;
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = t.anchorX + margin;
let top = t.anchorY - cardHeight / 3;
// flip horizontally if overflowing right
if (left + cardWidth > vw - margin) {
left = t.anchorX - cardWidth - margin;
}
// clamp vertically
if (top < margin) top = margin;
if (top + cardHeight > vh - margin) top = vh - cardHeight - margin;
return { top, left };
});
async function handleToggleFriend() {
const t = target();
if (!t) return;
try {
if (isFriend()) {
await tauri.removeFriend(t.peerId);
unmarkAsFriend(t.peerId);
} else {
await tauri.addFriend(t.peerId);
markAsFriend(t.peerId);
}
} catch (e) {
console.error("failed to toggle friend:", e);
}
}
async function handleCopyPeerId() {
const t = target();
if (!t) return;
try {
await navigator.clipboard.writeText(t.peerId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard api may fail outside secure contexts
}
}
function handleOpenFullProfile() {
const t = target();
if (t) openProfileModal(t.peerId);
}
const statusLabel = () => {
const s = status();
if (s === "Online") return "online";
if (s === "Idle") return "idle";
return "offline";
};
const statusColor = () => {
const s = status();
if (s === "Online") return "bg-success";
if (s === "Idle") return "bg-warning";
return "bg-gray-300";
};
return (
<Show when={isOpen()}>
<Portal>
<div
ref={cardRef}
class="fixed z-2000 w-[320px] bg-gray-900 border border-white/20 animate-scale-in overflow-hidden"
style={{
top: `${cardPosition().top}px`,
left: `${cardPosition().left}px`,
}}
>
{/* header banner */}
<div class="h-16 bg-linear-to-r from-orange/30 to-orange/10" />
{/* avatar overlapping the banner */}
<div class="px-4 -mt-8">
<button
type="button"
class="cursor-pointer"
onClick={handleOpenFullProfile}
>
<Avatar
name={displayName()}
size="xl"
status={status()}
showStatus
/>
</button>
</div>
{/* user info */}
<div class="px-4 pt-2 pb-3">
<div class="flex items-center gap-2">
<button
type="button"
class="text-[18px] font-semibold text-white truncate hover:text-orange transition-colors duration-200 cursor-pointer"
onClick={handleOpenFullProfile}
>
{displayName()}
</button>
<Show when={isSelf()}>
<span class="text-[11px] font-mono text-white/40 shrink-0">
(you)
</span>
</Show>
</div>
{/* status indicator */}
<div class="flex items-center gap-1.5 mt-1">
<div class={`w-2 h-2 rounded-full ${statusColor()}`} />
<span class="text-[12px] font-mono text-white/50">
{statusLabel()}
</span>
</div>
{/* bio */}
<Show when={bio()}>
<p class="text-[13px] text-white/70 mt-2 leading-relaxed line-clamp-3">
{bio()}
</p>
</Show>
</div>
{/* metadata section */}
<div class="border-t border-white/10 mx-4" />
<div class="px-4 py-3 space-y-2">
{/* roles */}
<Show when={roles().length > 0}>
<div>
<span class="text-[11px] font-mono uppercase tracking-[0.05em] text-white/40">
roles
</span>
<div class="flex flex-wrap gap-1.5 mt-1">
{roles().map((role) => (
<span class="text-[11px] font-mono px-2 py-0.5 bg-orange/15 text-orange border border-orange/30">
{role}
</span>
))}
</div>
</div>
</Show>
{/* joined date */}
<Show when={joinedAt() > 0}>
<div>
<span class="text-[11px] font-mono uppercase tracking-[0.05em] text-white/40">
member since
</span>
<p class="text-[12px] font-mono text-white/60 mt-0.5">
{formatTime(joinedAt())}
</p>
</div>
</Show>
{/* peer id */}
<div>
<span class="text-[11px] font-mono uppercase tracking-[0.05em] text-white/40">
peer id
</span>
<button
type="button"
class="flex items-center gap-1.5 mt-0.5 group cursor-pointer"
onClick={handleCopyPeerId}
>
<span class="text-[11px] font-mono text-white/40 group-hover:text-white/60 transition-colors duration-200 truncate max-w-55">
{target()?.peerId}
</span>
<Show
when={copied()}
fallback={
<Copy
size={12}
class="shrink-0 text-white/30 group-hover:text-white/50 transition-colors duration-200"
/>
}
>
<Check size={12} class="shrink-0 text-success" />
</Show>
</button>
</div>
</div>
{/* action buttons */}
<Show when={!isSelf()}>
<div class="border-t border-white/10 mx-4" />
<div class="px-4 py-3 flex gap-2">
<button
type="button"
class={`flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium transition-colors duration-200 cursor-pointer ${
isFriend()
? "text-red-400 border border-red-400/30 hover:bg-red-400/10"
: "text-orange border border-orange/30 hover:bg-orange/10"
}`}
onClick={handleToggleFriend}
>
<Show
when={isFriend()}
fallback={
<>
<UserPlus size={14} />
add friend
</>
}
>
<UserMinus size={14} />
remove friend
</Show>
</button>
</div>
</Show>
</div>
</Portal>
</Show>
);
};
export default ProfileCard;

View File

@ -0,0 +1,464 @@
import type { Component } from "solid-js";
import {
Show,
For,
createMemo,
createSignal,
onMount,
onCleanup,
} from "solid-js";
import { Portal } from "solid-js/web";
import {
X,
UserPlus,
UserMinus,
Copy,
Check,
Shield,
Clock,
Fingerprint,
Server,
Info,
} from "lucide-solid";
import Avatar from "./Avatar";
import { profileModalPeerId, closeProfileModal } from "../../stores/ui";
import { members } from "../../stores/members";
import {
knownPeers,
markAsFriend,
unmarkAsFriend,
} from "../../stores/directory";
import { identity } from "../../stores/identity";
import { communities } from "../../stores/communities";
import * as tauri from "../../lib/tauri";
import { formatTime } from "../../lib/utils";
const ProfileModal: Component = () => {
const [copiedId, setCopiedId] = createSignal(false);
const [copiedKey, setCopiedKey] = createSignal(false);
const [showPeerIdInfo, setShowPeerIdInfo] = createSignal(false);
const [showPublicKeyInfo, setShowPublicKeyInfo] = createSignal(false);
const peerId = () => profileModalPeerId();
const isOpen = () => peerId() !== null;
const isSelf = () => peerId() === identity()?.peer_id;
const memberInfo = createMemo(() => {
const id = peerId();
if (!id) return null;
return members().find((m) => m.peer_id === id) ?? null;
});
const directoryInfo = createMemo(() => {
const id = peerId();
if (!id) return null;
return knownPeers().find((p) => p.peer_id === id) ?? null;
});
const displayName = () =>
memberInfo()?.display_name ?? directoryInfo()?.display_name ?? "Unknown";
const bio = () =>
directoryInfo()?.bio || (isSelf() ? identity()?.bio : "") || "";
const isFriend = () => directoryInfo()?.is_friend ?? false;
const status = () => memberInfo()?.status ?? "Offline";
const roles = () => memberInfo()?.roles ?? [];
const joinedAt = () => memberInfo()?.joined_at ?? 0;
const publicKey = () =>
directoryInfo()?.public_key ?? identity()?.public_key ?? "";
const lastSeen = () => directoryInfo()?.last_seen ?? 0;
const trustLevel = () => memberInfo()?.trust_level ?? 0;
// communities the user is a member of (only meaningful for self right now,
// but the structure supports it once we track per-community membership)
const mutualCommunities = createMemo(() => {
if (isSelf()) return communities();
// for remote peers we don't have cross-community membership data yet,
// so return the current community if they're a member of it
const id = peerId();
if (!id) return [];
const isMember = members().some((m) => m.peer_id === id);
if (!isMember) return [];
// just show active community for now
return communities().slice(0, 1);
});
const createdAt = () => {
if (isSelf()) return identity()?.created_at ?? 0;
return 0;
};
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") closeProfileModal();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeProfileModal();
}
onMount(() => {
document.addEventListener("keydown", handleKeydown);
});
onCleanup(() => {
document.removeEventListener("keydown", handleKeydown);
});
async function handleToggleFriend() {
const id = peerId();
if (!id) return;
try {
if (isFriend()) {
await tauri.removeFriend(id);
unmarkAsFriend(id);
} else {
await tauri.addFriend(id);
markAsFriend(id);
}
} catch (e) {
console.error("failed to toggle friend:", e);
}
}
async function copyToClipboard(text: string, setter: (v: boolean) => void) {
try {
await navigator.clipboard.writeText(text);
setter(true);
setTimeout(() => setter(false), 2000);
} catch {
// clipboard api may fail outside secure contexts
}
}
const statusLabel = () => {
const s = status();
if (s === "Online") return "online";
if (s === "Idle") return "idle";
return "offline";
};
const statusColor = () => {
const s = status();
if (s === "Online") return "bg-success";
if (s === "Idle") return "bg-warning";
return "bg-gray-300";
};
// truncate public key for display
const truncatedKey = () => {
const key = publicKey();
if (!key || key.length < 20) return key;
return `${key.slice(0, 12)}...${key.slice(-12)}`;
};
return (
<Show when={isOpen()}>
<Portal>
<div
class="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 animate-fade-in"
onClick={handleBackdropClick}
>
<div class="bg-gray-900 border-2 border-white/20 w-full max-w-130 mx-4 animate-scale-in relative overflow-hidden">
{/* banner */}
<div class="h-24 bg-linear-to-r from-orange/30 via-orange/15 to-orange/5 relative">
<button
type="button"
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-white/60 hover:text-white transition-colors duration-200 cursor-pointer bg-black/30 hover:bg-black/50"
onClick={closeProfileModal}
>
<X size={18} />
</button>
</div>
{/* avatar overlapping banner */}
<div class="px-6 -mt-10 flex items-end gap-4">
<div class="shrink-0">
<Avatar
name={displayName()}
size="xl"
status={status()}
showStatus
/>
</div>
<div class="pb-1 min-w-0 flex-1">
<div class="flex items-center gap-2">
<h2 class="text-[22px] font-bold text-white truncate">
{displayName()}
</h2>
<Show when={isSelf()}>
<span class="text-[11px] font-mono text-white/40 shrink-0">
(you)
</span>
</Show>
</div>
<div class="flex items-center gap-1.5">
<div class={`w-2 h-2 rounded-full ${statusColor()}`} />
<span class="text-[12px] font-mono text-white/50">
{statusLabel()}
</span>
</div>
</div>
</div>
{/* body */}
<div class="px-6 pt-4 pb-5 space-y-5 max-h-[60vh] overflow-y-auto">
{/* bio section */}
<Show when={bio()}>
<div>
<SectionLabel text="about me" />
<p class="text-[14px] text-white/80 leading-relaxed whitespace-pre-wrap">
{bio()}
</p>
</div>
</Show>
{/* roles */}
<Show when={roles().length > 0}>
<div>
<SectionLabel text="roles" />
<div class="flex flex-wrap gap-1.5">
<For each={roles()}>
{(role) => (
<span class="text-[12px] font-mono px-2.5 py-1 bg-orange/15 text-orange border border-orange/30">
{role}
</span>
)}
</For>
</div>
</div>
</Show>
{/* info grid */}
<div>
<SectionLabel text="info" />
<div class="space-y-3">
{/* member since */}
<Show when={joinedAt() > 0}>
<InfoRow
icon={Clock}
label="member since"
value={formatTime(joinedAt())}
/>
</Show>
{/* account created */}
<Show when={createdAt() > 0}>
<InfoRow
icon={Clock}
label="account created"
value={formatTime(createdAt())}
/>
</Show>
{/* last seen (for non-online peers) */}
<Show
when={!isSelf() && status() !== "Online" && lastSeen() > 0}
>
<InfoRow
icon={Clock}
label="last seen"
value={formatTime(lastSeen())}
/>
</Show>
{/* trust level */}
<Show when={trustLevel() > 0}>
<InfoRow
icon={Shield}
label="trust level"
value={`${trustLevel()}`}
/>
</Show>
</div>
</div>
{/* mutual communities */}
<Show when={!isSelf() && mutualCommunities().length > 0}>
<div>
<SectionLabel text="mutual communities" />
<div class="space-y-1.5">
<For each={mutualCommunities()}>
{(community) => (
<div class="flex items-center gap-2.5 px-3 py-2 bg-black/30 border border-white/5">
<Server size={14} class="text-white/40 shrink-0" />
<span class="text-[13px] text-white/70 truncate">
{community.name}
</span>
</div>
)}
</For>
</div>
</div>
</Show>
{/* cryptographic identity */}
<div>
<SectionLabel text="identity" />
<div class="space-y-2.5">
{/* peer id */}
<div class="flex items-start gap-2.5">
<Fingerprint
size={14}
class="text-white/30 shrink-0 mt-0.5"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-mono text-white/40">
peer id
</span>
<button
type="button"
class="cursor-pointer text-white/25 hover:text-white/50 transition-colors duration-200"
onClick={() => setShowPeerIdInfo((v) => !v)}
>
<Info size={11} />
</button>
</div>
<Show when={showPeerIdInfo()}>
<p class="text-[11px] text-white/40 leading-relaxed mt-1 mb-1.5">
a unique identifier for this user on the dusk
peer-to-peer network. every peer gets one when they
create their account. it's derived from their
cryptographic keypair so it can't be faked.
</p>
</Show>
<button
type="button"
class="flex items-center gap-1.5 group cursor-pointer mt-0.5"
onClick={() => copyToClipboard(peerId()!, setCopiedId)}
>
<span class="text-[12px] font-mono text-white/50 group-hover:text-white/70 transition-colors duration-200 break-all">
{peerId()}
</span>
<Show
when={copiedId()}
fallback={
<Copy
size={12}
class="shrink-0 text-white/30 group-hover:text-white/50 transition-colors duration-200"
/>
}
>
<Check size={12} class="shrink-0 text-success" />
</Show>
</button>
</div>
</div>
{/* public key */}
<Show when={publicKey()}>
<div class="flex items-start gap-2.5">
<Shield size={14} class="text-white/30 shrink-0 mt-0.5" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-mono text-white/40">
public key
</span>
<button
type="button"
class="cursor-pointer text-white/25 hover:text-white/50 transition-colors duration-200"
onClick={() => setShowPublicKeyInfo((v) => !v)}
>
<Info size={11} />
</button>
</div>
<Show when={showPublicKeyInfo()}>
<p class="text-[11px] text-white/40 leading-relaxed mt-1 mb-1.5">
a cryptographic key that proves this user's
identity. every message they send is signed with a
matching private key, so you can trust it really
came from them and wasn't tampered with.
</p>
</Show>
<button
type="button"
class="flex items-center gap-1.5 group cursor-pointer mt-0.5"
onClick={() =>
copyToClipboard(publicKey(), setCopiedKey)
}
>
<span class="text-[12px] font-mono text-white/50 group-hover:text-white/70 transition-colors duration-200">
{truncatedKey()}
</span>
<Show
when={copiedKey()}
fallback={
<Copy
size={12}
class="shrink-0 text-white/30 group-hover:text-white/50 transition-colors duration-200"
/>
}
>
<Check size={12} class="shrink-0 text-success" />
</Show>
</button>
</div>
</div>
</Show>
</div>
</div>
</div>
{/* action footer */}
<Show when={!isSelf()}>
<div class="border-t border-white/10 px-6 py-4 flex gap-3">
<button
type="button"
class={`flex items-center gap-2 px-4 py-2 text-[13px] font-medium transition-colors duration-200 cursor-pointer ${
isFriend()
? "text-red-400 border border-red-400/30 hover:bg-red-400/10"
: "text-orange border border-orange/30 hover:bg-orange/10"
}`}
onClick={handleToggleFriend}
>
<Show
when={isFriend()}
fallback={
<>
<UserPlus size={16} />
add friend
</>
}
>
<UserMinus size={16} />
remove friend
</Show>
</button>
</div>
</Show>
</div>
</div>
</Portal>
</Show>
);
};
// reusable section label
const SectionLabel: Component<{ text: string }> = (props) => (
<span class="block text-[11px] font-mono uppercase tracking-[0.05em] text-white/40 mb-2">
{props.text}
</span>
);
// reusable info row with icon
const InfoRow: Component<{
icon: typeof Clock;
label: string;
value: string;
}> = (props) => (
<div class="flex items-center gap-2.5">
<props.icon size={14} class="text-white/30 shrink-0" />
<div class="flex items-baseline gap-2 min-w-0">
<span class="text-[11px] font-mono text-white/40 shrink-0">
{props.label}
</span>
<span class="text-[13px] font-mono text-white/60 truncate">
{props.value}
</span>
</div>
</div>
);
export default ProfileModal;

View File

@ -4,6 +4,7 @@ import UserFooter from "./UserFooter";
interface SidebarLayoutProps { interface SidebarLayoutProps {
header?: JSX.Element; header?: JSX.Element;
footer?: JSX.Element; footer?: JSX.Element;
beforeFooter?: JSX.Element;
showFooter?: boolean; showFooter?: boolean;
showFooterSettings?: boolean; showFooterSettings?: boolean;
onFooterSettingsClick?: () => void; onFooterSettingsClick?: () => void;
@ -24,6 +25,9 @@ const SidebarLayout: Component<ParentProps<SidebarLayoutProps>> = (props) => {
{props.children} {props.children}
</div> </div>
{/* voice controls or other content above the user footer */}
{props.beforeFooter}
{/* footer */} {/* footer */}
{props.showFooter && ( {props.showFooter && (
<UserFooter <UserFooter

View File

@ -74,12 +74,18 @@ const UserDirectoryModal: Component<UserDirectoryModalProps> = (props) => {
} }
} }
function handleMessagePeer(peerId: string, displayName: string) { async function handleMessagePeer(peerId: string, displayName: string) {
// start a dm conversation with this peer // start a dm conversation with this peer
try {
await tauri.openDMConversation(peerId, displayName);
} catch {
// fallback for demo mode or if backend call fails
}
addDMConversation({ addDMConversation({
peer_id: peerId, peer_id: peerId,
display_name: displayName, display_name: displayName,
status: "Online", last_message: null,
last_message_time: null,
unread_count: 0, unread_count: 0,
}); });
setActiveDM(peerId); setActiveDM(peerId);

View File

@ -1,6 +1,6 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import { Show } from "solid-js"; import { Show } from "solid-js";
import { Hash, Pin, Search, Users } from "lucide-solid"; import { Hash, Pin, Search, Users, WifiOff } from "lucide-solid";
import ServerList from "./ServerList"; import ServerList from "./ServerList";
import ChannelList from "./ChannelList"; import ChannelList from "./ChannelList";
import ChatArea from "./ChatArea"; import ChatArea from "./ChatArea";
@ -20,11 +20,13 @@ import { activeCommunityId } from "../../stores/communities";
import { activeDMPeerId } from "../../stores/dms"; import { activeDMPeerId } from "../../stores/dms";
import { activeChannel } from "../../stores/channels"; import { activeChannel } from "../../stores/channels";
import { sidebarWidth, updateSidebarWidth } from "../../stores/sidebar"; import { sidebarWidth, updateSidebarWidth } from "../../stores/sidebar";
import { relayConnected, nodeStatus } from "../../stores/connection";
interface AppLayoutProps { interface AppLayoutProps {
onSendMessage: (content: string) => void; onSendMessage: (content: string) => void;
onTyping: () => void; onTyping: () => void;
onSendDM: (content: string) => void; onSendDM: (content: string) => void;
onDMTyping: () => void;
} }
const AppLayout: Component<AppLayoutProps> = (props) => { const AppLayout: Component<AppLayoutProps> = (props) => {
@ -33,6 +35,9 @@ const AppLayout: Component<AppLayoutProps> = (props) => {
const channel = () => activeChannel(); const channel = () => activeChannel();
const showSidebar = () => sidebarVisible() && !isMobile() && !isHome(); const showSidebar = () => sidebarVisible() && !isMobile() && !isHome();
const showChannelHeader = () => !isHome() && channel(); const showChannelHeader = () => !isHome() && channel();
// only warn about relay when the node is actually running
const showRelayWarning = () =>
!relayConnected() && nodeStatus() === "running";
return ( return (
<div class="flex h-screen w-screen overflow-hidden bg-black"> <div class="flex h-screen w-screen overflow-hidden bg-black">
@ -42,97 +47,112 @@ const AppLayout: Component<AppLayoutProps> = (props) => {
</Show> </Show>
{/* main content area */} {/* main content area */}
<div class="flex flex-1 overflow-hidden min-w-0"> <div class="flex flex-col flex-1 overflow-hidden min-w-0">
<Show <Show when={showRelayWarning()}>
when={isHome()} <div class="shrink-0 flex items-center gap-2 px-4 py-2 bg-orange/10 border-b border-orange/20">
fallback={ <WifiOff size={14} class="shrink-0 text-orange" />
<> <span class="text-[13px] font-mono text-orange">
{/* community view: channel list + chat */} relay unreachable -- WAN connectivity limited, retrying in
<Show when={channelListVisible()}> background
<ResizablePanel </span>
width={sidebarWidth()} </div>
minWidth={300} </Show>
maxWidth={600}
side="left"
onResize={updateSidebarWidth}
>
<ChannelList />
</ResizablePanel>
</Show>
{/* chat + header container */} <div class="flex flex-1 overflow-hidden min-w-0">
<div class="flex flex-col flex-1 min-w-0"> <Show
{/* channel header */} when={isHome()}
<Show when={showChannelHeader()}> fallback={
<div class="h-15 shrink-0 border-b border-white/10 bg-black flex flex-col justify-end"> <>
<div class="h-12 flex items-center justify-between px-4"> {/* community view: channel list + chat */}
<div class="flex items-center gap-2 min-w-0"> <Show when={channelListVisible()}>
<Hash size={20} class="shrink-0 text-white/40" /> <ResizablePanel
<span class="text-[16px] font-bold text-white truncate"> width={sidebarWidth()}
{channel()!.name} minWidth={300}
</span> maxWidth={600}
<Show when={channel()!.topic}> side="left"
<div class="w-px h-5 bg-white/20 mx-2 shrink-0" /> onResize={updateSidebarWidth}
<span class="text-[14px] text-white/40 truncate"> >
{channel()!.topic} <ChannelList />
</span> </ResizablePanel>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<IconButton label="Pinned messages">
<Pin size={18} />
</IconButton>
<IconButton label="Search">
<Search size={18} />
</IconButton>
<IconButton
label="Toggle member list"
active={sidebarVisible()}
onClick={toggleSidebar}
>
<Users size={18} />
</IconButton>
</div>
</div>
</div>
</Show> </Show>
<div class="flex flex-1 min-w-0"> {/* chat + header container */}
<ChatArea <div class="flex flex-col flex-1 min-w-0">
onSendMessage={props.onSendMessage} {/* channel header */}
onTyping={props.onTyping} <Show when={showChannelHeader()}>
/> <div class="h-15 shrink-0 border-b border-white/10 bg-black flex flex-col justify-end">
<Show when={showSidebar()}> <div class="h-12 flex items-center justify-between px-4">
<ResizablePanel <div class="flex items-center gap-2 min-w-0">
width={sidebarWidth()} <Hash size={20} class="shrink-0 text-white/40" />
minWidth={300} <span class="text-[16px] font-bold text-white truncate">
maxWidth={600} {channel()!.name}
side="right" </span>
onResize={updateSidebarWidth} <Show when={channel()!.topic}>
> <div class="w-px h-5 bg-white/20 mx-2 shrink-0" />
<UserSidebar /> <span class="text-[14px] text-white/40 truncate">
</ResizablePanel> {channel()!.topic}
</span>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<IconButton label="Pinned messages">
<Pin size={18} />
</IconButton>
<IconButton label="Search">
<Search size={18} />
</IconButton>
<IconButton
label="Toggle member list"
active={sidebarVisible()}
onClick={toggleSidebar}
>
<Users size={18} />
</IconButton>
</div>
</div>
</div>
</Show> </Show>
<div class="flex flex-1 min-w-0">
<ChatArea
onSendMessage={props.onSendMessage}
onTyping={props.onTyping}
/>
<Show when={showSidebar()}>
<ResizablePanel
width={sidebarWidth()}
minWidth={300}
maxWidth={600}
side="right"
onResize={updateSidebarWidth}
>
<UserSidebar />
</ResizablePanel>
</Show>
</div>
</div> </div>
</div> </>
</> }
}
>
{/* home view: dm sidebar + friends list or dm chat */}
<ResizablePanel
width={sidebarWidth()}
minWidth={300}
maxWidth={600}
side="left"
onResize={updateSidebarWidth}
> >
<DMSidebar /> {/* home view: dm sidebar + friends list or dm chat */}
</ResizablePanel> <ResizablePanel
<Show when={activeDMPeerId()} fallback={<HomeView />}> width={sidebarWidth()}
<DMChatArea onSendDM={props.onSendDM} /> minWidth={300}
maxWidth={600}
side="left"
onResize={updateSidebarWidth}
>
<DMSidebar />
</ResizablePanel>
<Show when={activeDMPeerId()} fallback={<HomeView />}>
<DMChatArea
onSendDM={props.onSendDM}
onTyping={props.onDMTyping}
/>
</Show>
</Show> </Show>
</Show> </div>
</div> </div>
</div> </div>
); );

View File

@ -1,23 +1,334 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import { For, Show, createSignal } from "solid-js"; import { For, Show, createSignal } from "solid-js";
import { Hash, Volume2, Plus, ChevronDown } from "lucide-solid"; import {
DragDropProvider,
DragDropSensors,
SortableProvider,
createSortable,
closestCenter,
} from "@thisbeyond/solid-dnd";
import { Hash, Volume2, Plus, ChevronDown, FolderPlus, Mic, MicOff, Headphones, HeadphoneOff, PhoneOff } from "lucide-solid";
import { import {
channels, channels,
categories,
activeChannelId, activeChannelId,
setActiveChannel, setActiveChannel,
setChannels,
reorderChannels,
} from "../../stores/channels"; } from "../../stores/channels";
import { activeCommunity } from "../../stores/communities"; import { activeCommunity } from "../../stores/communities";
import { openModal } from "../../stores/ui"; import { openModal } from "../../stores/ui";
import {
voiceChannelId,
voiceParticipants,
isInVoice,
joinVoice,
leaveVoice,
localMediaState,
toggleMute,
toggleDeafen,
voiceConnectionState,
} from "../../stores/voice";
import { identity } from "../../stores/identity";
import SidebarLayout from "../common/SidebarLayout"; import SidebarLayout from "../common/SidebarLayout";
import Avatar from "../common/Avatar";
import type { ChannelMeta } from "../../lib/types";
interface GhostInfo {
channel: ChannelMeta;
position: "above" | "below";
}
interface SortableChannelProps {
channel: ChannelMeta;
isActive: boolean;
isInVoiceChannel: boolean;
icon: typeof Hash;
onClick: () => void;
ghost: GhostInfo | null;
}
// translucent preview of the dragged channel at its drop position
const GhostChannel: Component<{ name: string; icon: typeof Hash }> = (
props,
) => (
<div class="flex items-center gap-2 w-full h-10 pr-2 pl-3 border border-dashed border-orange/30 bg-orange/5 text-white/25 pointer-events-none">
<props.icon size={16} class="shrink-0 text-orange/25" />
<span class="truncate text-[16px]">{props.name}</span>
</div>
);
const SortableChannel: Component<SortableChannelProps> = (props) => {
const sortable = createSortable(props.channel.id);
// determine styling based on active and voice channel state
const getContainerClass = () => {
if (props.isInVoiceChannel) {
// user is currently in this voice channel
return "bg-orange/20 text-white border-l-4 border-orange pl-1";
}
if (props.isActive) {
return "bg-gray-800 text-white border-l-4 border-orange pl-1";
}
return "text-white/60 hover:bg-gray-800 hover:text-white pl-2";
};
return (
<>
<Show when={props.ghost?.position === "above"}>
<GhostChannel name={props.ghost!.channel.name} icon={props.icon} />
</Show>
<div
ref={sortable.ref}
class={`flex items-center gap-2 w-full h-10 pr-2 pl-3 text-[16px] transition-all duration-200 cursor-pointer group ${getContainerClass()} ${
sortable.isActiveDraggable ? "opacity-40" : ""
}`}
onClick={props.onClick}
{...sortable.dragActivators}
>
<props.icon size={16} class="shrink-0 text-white/40" />
<span class="truncate">{props.channel.name}</span>
</div>
<Show when={props.ghost?.position === "below"}>
<GhostChannel name={props.ghost!.channel.name} icon={props.icon} />
</Show>
</>
);
};
const ChannelList: Component = () => { const ChannelList: Component = () => {
const [textCollapsed, setTextCollapsed] = createSignal(false); // track collapsed state per section via a map keyed by section id
const [voiceCollapsed, setVoiceCollapsed] = createSignal(false); const [collapsedSections, setCollapsedSections] = createSignal<
Record<string, boolean>
>({});
const [activeId, setActiveId] = createSignal<string | null>(null);
const [droppableId, setDroppableId] = createSignal<string | null>(null);
// channels without a category, grouped by kind
const uncategorizedText = () =>
channels().filter((c) => !c.category_id && c.kind === "Text");
const uncategorizedVoice = () =>
channels().filter((c) => !c.category_id && c.kind === "Voice");
// channels belonging to a specific category
const channelsForCategory = (catId: string) =>
channels().filter((c) => c.category_id === catId);
const textChannels = () => channels().filter((c) => c.kind === "Text");
const voiceChannels = () => channels().filter((c) => c.kind === "Voice");
const community = () => activeCommunity(); const community = () => activeCommunity();
const toggleSection = (id: string) => {
setCollapsedSections((prev) => ({ ...prev, [id]: !prev[id] }));
};
const isSectionCollapsed = (id: string) => !!collapsedSections()[id];
const handleDragStart = ({ draggable }: any) => {
setActiveId(draggable.id as string);
setDroppableId(null);
};
const handleDragOver = ({ droppable }: any) => {
if (droppable) {
setDroppableId(droppable.id as string);
}
};
const handleDragEnd = ({ draggable, droppable }: any) => {
setActiveId(null);
setDroppableId(null);
if (!droppable) return;
const fromId = draggable.id as string;
const toId = droppable.id as string;
const allChannels = channels();
const fromChannel = allChannels.find((c) => c.id === fromId);
if (!fromChannel) return;
const toChannel = allChannels.find((c) => c.id === toId);
// only allow dragging within the same category and kind
if (fromChannel.kind !== toChannel?.kind) return;
if (fromChannel.category_id !== toChannel?.category_id) return;
// get channels in the same group
const groupChannels = allChannels.filter(
(c) =>
c.kind === fromChannel.kind &&
c.category_id === fromChannel.category_id,
);
const ids = groupChannels.map((c) => c.id);
const fromIndex = ids.indexOf(fromId);
const toIndex = ids.indexOf(toId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
const newOrder = [...ids];
newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, fromId);
// update local state with new positions
const otherChannels = allChannels.filter(
(c) =>
c.kind !== fromChannel.kind ||
c.category_id !== fromChannel.category_id,
);
const reorderedChannels = newOrder.map((id, index) => {
const channel = groupChannels.find((c) => c.id === id)!;
return { ...channel, position: index };
});
setChannels([...otherChannels, ...reorderedChannels]);
reorderChannels(newOrder);
};
// compute ghost placement for a given channel in its list
const getGhost = (
channelId: string,
channelList: ChannelMeta[],
): GhostInfo | null => {
const active = activeId();
const droppable = droppableId();
if (!active || !droppable || active === droppable) return null;
if (channelId !== droppable) return null;
const draggedChannel = channelList.find((c) => c.id === active);
if (!draggedChannel) return null;
const ids = channelList.map((c) => c.id);
const fromIndex = ids.indexOf(active);
const toIndex = ids.indexOf(droppable);
if (fromIndex === -1 || toIndex === -1) return null;
return {
channel: draggedChannel,
position: fromIndex < toIndex ? "below" : "above",
};
};
// renders a collapsible channel section header
const SectionHeader: Component<{
sectionId: string;
label: string;
showAdd?: boolean;
}> = (props) => (
<div class="flex items-center justify-between pr-2">
<button
type="button"
class="flex items-center gap-1 flex-1 px-2 py-1.5 text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 hover:text-white/80 transition-colors duration-200 cursor-pointer select-none"
onClick={() => toggleSection(props.sectionId)}
>
<ChevronDown
size={12}
class="transition-transform duration-300"
style={{
transform: isSectionCollapsed(props.sectionId)
? "rotate(-90deg)"
: "rotate(0deg)",
}}
/>
{props.label}
</button>
<Show when={props.showAdd && community()}>
<button
type="button"
class="text-white/30 hover:text-white/60 transition-colors duration-200 cursor-pointer"
onClick={() => openModal("create-channel")}
>
<Plus size={14} />
</button>
</Show>
</div>
);
// participants currently in a voice channel, including the local user
const voiceChannelParticipants = () => {
const localUser = identity();
const remote = voiceParticipants().filter(
(p) => p.peer_id !== localUser?.peer_id,
);
const all = [];
if (localUser) {
all.push({
peer_id: localUser.peer_id,
display_name: localUser.display_name,
is_local: true,
muted: localMediaState().muted,
});
}
for (const p of remote) {
all.push({
peer_id: p.peer_id,
display_name: p.display_name,
is_local: false,
muted: p.media_state.muted,
});
}
return all;
};
const handleChannelClick = (channel: ChannelMeta) => {
if (channel.kind === "Voice") {
// clicking a voice channel joins it (or switches to it)
const currentVoice = voiceChannelId();
if (currentVoice === channel.id) return;
joinVoice(channel.community_id, channel.id);
} else {
setActiveChannel(channel.id);
}
};
// renders a list of channels with drag-and-drop support
const ChannelGroup: Component<{
sectionId: string;
channelList: ChannelMeta[];
}> = (props) => {
const ids = () => props.channelList.map((c) => c.id);
return (
<Show when={!isSectionCollapsed(props.sectionId)}>
<SortableProvider ids={ids()}>
<For each={props.channelList}>
{(channel) => (
<>
<SortableChannel
channel={channel}
isActive={channel.kind !== "Voice" && activeChannelId() === channel.id}
isInVoiceChannel={voiceChannelId() === channel.id}
icon={channel.kind === "Voice" ? Volume2 : Hash}
onClick={() => handleChannelClick(channel)}
ghost={getGhost(channel.id, props.channelList)}
/>
{/* discord-style participant list under active voice channels */}
<Show when={channel.kind === "Voice" && voiceChannelId() === channel.id && voiceConnectionState() === "connected"}>
<div class="pl-7 py-0.5">
<For each={voiceChannelParticipants()}>
{(participant) => (
<div class="flex items-center gap-2 px-2 py-1 text-white/60 hover:bg-white/5 transition-colors duration-150">
<Avatar name={participant.display_name} size="sm" />
<span class="text-[13px] truncate flex-1">
{participant.display_name}
</span>
<Show when={participant.muted}>
<MicOff size={12} class="shrink-0 text-white/30" />
</Show>
</div>
)}
</For>
</div>
</Show>
</>
)}
</For>
</SortableProvider>
</Show>
);
};
const header = ( const header = (
<div class="h-15 border-b border-white/10 flex flex-col justify-end"> <div class="h-15 border-b border-white/10 flex flex-col justify-end">
<div class="h-12 flex items-center justify-between px-4"> <div class="h-12 flex items-center justify-between px-4">
@ -42,96 +353,162 @@ const ChannelList: Component = () => {
); );
const body = ( const body = (
<div class="py-3"> <DragDropProvider
{/* text channels */} onDragStart={handleDragStart}
<Show when={textChannels().length > 0}> onDragOver={handleDragOver}
<button onDragEnd={handleDragEnd}
type="button" collisionDetector={closestCenter}
class="flex items-center gap-1 w-full px-2 py-1.5 text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 hover:text-white/80 transition-colors duration-200 cursor-pointer select-none" >
onClick={() => setTextCollapsed((v) => !v)} <DragDropSensors />
> <div class="py-3">
<ChevronDown {/* uncategorized text channels */}
size={12} <Show when={uncategorizedText().length > 0}>
class="transition-transform duration-300" <SectionHeader
style={{ sectionId="uncategorized-text"
transform: textCollapsed() ? "rotate(-90deg)" : "rotate(0deg)", label="text channels"
}} showAdd
/> />
text channels <ChannelGroup
</button> sectionId="uncategorized-text"
<Show when={!textCollapsed()}> channelList={uncategorizedText()}
<For each={textChannels()}>
{(channel) => (
<button
type="button"
class={`flex items-center gap-2 w-full h-10 px-2 text-[16px] transition-all duration-200 cursor-pointer group ${
activeChannelId() === channel.id
? "bg-gray-800 text-white border-l-4 border-orange pl-1.5"
: "text-white/60 hover:bg-gray-800 hover:text-white"
}`}
onClick={() => setActiveChannel(channel.id)}
>
<Hash size={16} class="shrink-0 text-white/40" />
<span class="truncate">{channel.name}</span>
</button>
)}
</For>
</Show>
</Show>
{/* voice channels */}
<Show when={voiceChannels().length > 0}>
<button
type="button"
class="flex items-center gap-1 w-full px-2 py-1.5 mt-2 text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 hover:text-white/80 transition-colors duration-200 cursor-pointer select-none"
onClick={() => setVoiceCollapsed((v) => !v)}
>
<ChevronDown
size={12}
class="transition-transform duration-300"
style={{
transform: voiceCollapsed() ? "rotate(-90deg)" : "rotate(0deg)",
}}
/> />
voice channels
</button>
<Show when={!voiceCollapsed()}>
<For each={voiceChannels()}>
{(channel) => (
<button
type="button"
class={`flex items-center gap-2 w-full h-10 px-2 text-[16px] transition-all duration-200 cursor-pointer ${
activeChannelId() === channel.id
? "bg-gray-800 text-white border-l-4 border-orange pl-1.5"
: "text-white/60 hover:bg-gray-800 hover:text-white"
}`}
onClick={() => setActiveChannel(channel.id)}
>
<Volume2 size={16} class="shrink-0 text-white/40" />
<span class="truncate">{channel.name}</span>
</button>
)}
</For>
</Show> </Show>
</Show>
{/* add channel button */} {/* uncategorized voice channels */}
<Show when={community()}> <Show when={uncategorizedVoice().length > 0}>
<button <div class="mt-2">
type="button" <SectionHeader
class="flex items-center gap-2 w-full h-8 px-2 mt-2 text-[13px] text-white/30 hover:text-white/60 transition-colors duration-200 cursor-pointer" sectionId="uncategorized-voice"
onClick={() => openModal("create-channel")} label="voice channels"
showAdd
/>
<ChannelGroup
sectionId="uncategorized-voice"
channelList={uncategorizedVoice()}
/>
</div>
</Show>
{/* category sections */}
<For each={categories()}>
{(cat) => {
const catChannels = () => channelsForCategory(cat.id);
return (
<Show when={catChannels().length > 0 || community()}>
<div class="mt-2">
<SectionHeader sectionId={cat.id} label={cat.name} showAdd />
<ChannelGroup
sectionId={cat.id}
channelList={catChannels()}
/>
</div>
</Show>
);
}}
</For>
{/* create channel / create category buttons when no channels exist yet */}
<Show
when={
channels().length === 0 && categories().length === 0 && community()
}
> >
<Plus size={14} /> <div class="px-2 mt-2">
<span>add channel</span> <button
</button> type="button"
</Show> class="flex items-center gap-2 w-full px-3 py-2 text-[14px] text-white/40 hover:text-white/60 transition-colors duration-200 cursor-pointer"
</div> onClick={() => openModal("create-channel")}
>
<Plus size={14} />
create channel
</button>
</div>
</Show>
{/* create category button */}
<Show when={community()}>
<div class="px-2 mt-3 border-t border-white/5 pt-3">
<button
type="button"
class="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] font-mono text-white/30 hover:text-white/50 transition-colors duration-200 cursor-pointer"
onClick={() => openModal("create-category")}
>
<FolderPlus size={12} />
create category
</button>
</div>
</Show>
</div>
</DragDropProvider>
); );
// compact voice connection panel rendered above the user footer
const voicePanel = () => {
if (!isInVoice()) return null;
const channelName = () => {
const id = voiceChannelId();
return channels().find((c) => c.id === id)?.name ?? "voice";
};
return (
<div class="shrink-0 border-t border-white/10 bg-gray-950">
<div class="flex items-center justify-between px-3 py-2">
<div class="flex flex-col min-w-0">
<span class="text-[11px] font-mono font-semibold text-green-400 uppercase tracking-wider">
voice connected
</span>
<span class="text-[12px] text-white/50 truncate">
{channelName()}
</span>
</div>
<button
type="button"
class="p-1.5 text-white/40 hover:text-red-400 transition-colors duration-200 cursor-pointer"
onClick={() => leaveVoice()}
title="Disconnect"
>
<PhoneOff size={16} />
</button>
</div>
<div class="flex items-center justify-center gap-1 px-3 pb-2">
<button
type="button"
class={`flex items-center justify-center w-8 h-8 transition-colors duration-200 cursor-pointer ${
localMediaState().muted
? "bg-red-500/20 text-red-400"
: "text-white/60 hover:text-white hover:bg-white/10"
}`}
onClick={() => toggleMute()}
title={localMediaState().muted ? "Unmute" : "Mute"}
>
<Show when={localMediaState().muted} fallback={<Mic size={16} />}>
<MicOff size={16} />
</Show>
</button>
<button
type="button"
class={`flex items-center justify-center w-8 h-8 transition-colors duration-200 cursor-pointer ${
localMediaState().deafened
? "bg-red-500/20 text-red-400"
: "text-white/60 hover:text-white hover:bg-white/10"
}`}
onClick={() => toggleDeafen()}
title={localMediaState().deafened ? "Undeafen" : "Deafen"}
>
<Show when={localMediaState().deafened} fallback={<Headphones size={16} />}>
<HeadphoneOff size={16} />
</Show>
</button>
</div>
</div>
);
};
return ( return (
<SidebarLayout <SidebarLayout
header={header} header={header}
beforeFooter={voicePanel()}
showFooter showFooter
showFooterSettings showFooterSettings
onFooterSettingsClick={() => openModal("settings")} onFooterSettingsClick={() => openModal("settings")}

View File

@ -12,12 +12,14 @@ interface ChatAreaProps {
onTyping: () => void; onTyping: () => void;
} }
// voice channels are joined inline from the sidebar and no longer
// render a full-screen view here -- the chat area always shows
// the selected text channel
const ChatArea: Component<ChatAreaProps> = (props) => { const ChatArea: Component<ChatAreaProps> = (props) => {
const channel = () => activeChannel(); const channel = () => activeChannel();
return ( return (
<div class="flex-1 flex flex-col min-w-0 bg-black"> <div class="flex-1 flex flex-col min-w-0 bg-black">
{/* message area */}
<Show <Show
when={channel()} when={channel()}
fallback={ fallback={

View File

@ -1,18 +1,57 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import { Show } from "solid-js"; import { Show, createMemo } from "solid-js";
import { AtSign } from "lucide-solid"; import { AtSign } from "lucide-solid";
import { activeDMConversation, dmMessages } from "../../stores/dms"; import {
activeDMConversation,
dmMessages,
dmTypingPeers,
} from "../../stores/dms";
import { onlinePeerIds } from "../../stores/members";
import MessageList from "../chat/MessageList"; import MessageList from "../chat/MessageList";
import MessageInput from "../chat/MessageInput"; import MessageInput from "../chat/MessageInput";
import TypingIndicator from "../chat/TypingIndicator";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import type { ChatMessage } from "../../lib/types";
interface DMChatAreaProps { interface DMChatAreaProps {
onSendDM: (content: string) => void; onSendDM: (content: string) => void;
onTyping: () => void;
} }
const DMChatArea: Component<DMChatAreaProps> = (props) => { const DMChatArea: Component<DMChatAreaProps> = (props) => {
const dm = () => activeDMConversation(); const dm = () => activeDMConversation();
// adapt DirectMessage[] to ChatMessage[] so the existing MessageList works
const adaptedMessages = createMemo((): ChatMessage[] =>
dmMessages().map((m) => ({
id: m.id,
channel_id: `dm_${m.from_peer === dm()?.peer_id ? m.from_peer : m.to_peer}`,
author_id: m.from_peer,
author_name: m.from_display_name,
content: m.content,
timestamp: m.timestamp,
edited: false,
})),
);
// derive peer online status from the members store or directory
const peerStatus = createMemo(() => {
const peerId = dm()?.peer_id;
if (!peerId) return "offline";
if (onlinePeerIds().has(peerId)) return "online";
return "offline";
});
// typing indicator names
const typingNames = createMemo(() => {
const typing = dmTypingPeers();
if (typing.length === 0) return [];
const peer = dm();
if (!peer) return [];
// for dms there's only ever one person who can be typing
return typing.includes(peer.peer_id) ? [peer.display_name] : [];
});
return ( return (
<div class="flex-1 flex flex-col min-w-0 bg-black"> <div class="flex-1 flex flex-col min-w-0 bg-black">
{/* dm header */} {/* dm header */}
@ -26,14 +65,10 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
</span> </span>
<span <span
class={`text-[12px] font-mono ml-1 ${ class={`text-[12px] font-mono ml-1 ${
dm()!.status === "Online" peerStatus() === "online" ? "text-success" : "text-white/30"
? "text-success"
: dm()!.status === "Idle"
? "text-warning"
: "text-white/30"
}`} }`}
> >
{dm()!.status.toLowerCase()} {peerStatus()}
</span> </span>
</Show> </Show>
</div> </div>
@ -42,7 +77,7 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
{/* conversation history */} {/* conversation history */}
<Show <Show
when={dmMessages().length > 0} when={adaptedMessages().length > 0}
fallback={ fallback={
<div class="flex-1 flex flex-col items-center justify-center"> <div class="flex-1 flex flex-col items-center justify-center">
<Show when={dm()}> <Show when={dm()}>
@ -58,7 +93,12 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
</div> </div>
} }
> >
<MessageList messages={dmMessages()} /> <MessageList messages={adaptedMessages()} />
</Show>
{/* typing indicator */}
<Show when={typingNames().length > 0}>
<TypingIndicator typingUsers={typingNames()} />
</Show> </Show>
{/* message input */} {/* message input */}
@ -66,7 +106,7 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
<MessageInput <MessageInput
channelName={dm()!.display_name} channelName={dm()!.display_name}
onSend={props.onSendDM} onSend={props.onSendDM}
onTyping={() => {}} onTyping={props.onTyping}
/> />
</Show> </Show>
</div> </div>

View File

@ -6,8 +6,11 @@ import {
activeDMPeerId, activeDMPeerId,
setActiveDM, setActiveDM,
clearDMUnread, clearDMUnread,
removeDMConversation,
} from "../../stores/dms"; } from "../../stores/dms";
import { onlinePeerIds } from "../../stores/members";
import { openModal } from "../../stores/ui"; import { openModal } from "../../stores/ui";
import * as tauri from "../../lib/tauri";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import Divider from "../common/Divider"; import Divider from "../common/Divider";
import SidebarLayout from "../common/SidebarLayout"; import SidebarLayout from "../common/SidebarLayout";
@ -23,9 +26,26 @@ const DMSidebar: Component = () => {
); );
}; };
// derive status from online peer set
function peerStatus(peerId: string): "Online" | "Offline" {
return onlinePeerIds().has(peerId) ? "Online" : "Offline";
}
function handleSelectDM(peerId: string) { function handleSelectDM(peerId: string) {
setActiveDM(peerId); setActiveDM(peerId);
clearDMUnread(peerId); clearDMUnread(peerId);
// mark as read in the backend
tauri.markDMRead(peerId).catch(() => {});
}
async function handleDeleteConversation(e: MouseEvent, peerId: string) {
e.stopPropagation();
try {
await tauri.deleteDMConversation(peerId);
removeDMConversation(peerId);
} catch (err) {
console.error("failed to delete dm conversation:", err);
}
} }
const header = ( const header = (
@ -86,6 +106,7 @@ const DMSidebar: Component = () => {
type="button" type="button"
class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer" class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
title="new dm" title="new dm"
onClick={() => openModal("directory")}
> >
<Plus size={14} /> <Plus size={14} />
</button> </button>
@ -118,7 +139,7 @@ const DMSidebar: Component = () => {
<Avatar <Avatar
name={dm.display_name} name={dm.display_name}
size="sm" size="sm"
status={dm.status} status={peerStatus(dm.peer_id)}
showStatus showStatus
/> />
<div class="flex-1 min-w-0 text-left"> <div class="flex-1 min-w-0 text-left">
@ -126,11 +147,21 @@ const DMSidebar: Component = () => {
<span class="text-[14px] font-medium truncate"> <span class="text-[14px] font-medium truncate">
{dm.display_name} {dm.display_name}
</span> </span>
<Show when={dm.unread_count > 0}> <div class="flex items-center gap-1 shrink-0">
<span class="w-5 h-5 flex items-center justify-center bg-orange text-white text-[11px] font-bold rounded-full shrink-0"> <Show when={dm.unread_count > 0}>
{dm.unread_count} <span class="w-5 h-5 flex items-center justify-center bg-orange text-white text-[11px] font-bold rounded-full">
</span> {dm.unread_count}
</Show> </span>
</Show>
<button
type="button"
class="w-5 h-5 flex items-center justify-center text-white/0 group-hover:text-white/30 hover:!text-red-400 transition-colors duration-200 cursor-pointer"
title="close conversation"
onClick={(e) => handleDeleteConversation(e, dm.peer_id)}
>
<X size={12} />
</button>
</div>
</div> </div>
<Show when={dm.last_message}> <Show when={dm.last_message}>
<p class="text-[12px] text-white/40 truncate mt-0.5"> <p class="text-[12px] text-white/40 truncate mt-0.5">

View File

@ -2,15 +2,16 @@ import type { Component } from "solid-js";
import { For, Show, createSignal, createMemo } from "solid-js"; import { For, Show, createSignal, createMemo } from "solid-js";
import { Users, MessageCircle, Search, UserPlus } from "lucide-solid"; import { Users, MessageCircle, Search, UserPlus } from "lucide-solid";
import { import {
dmConversations,
setActiveDM, setActiveDM,
clearDMUnread, clearDMUnread,
addDMConversation, addDMConversation,
} from "../../stores/dms"; } from "../../stores/dms";
import { knownPeers, friends } from "../../stores/directory"; import { knownPeers, friends } from "../../stores/directory";
import { onlinePeerIds } from "../../stores/members";
import { identity } from "../../stores/identity"; import { identity } from "../../stores/identity";
import { peerCount, nodeStatus } from "../../stores/connection"; import { peerCount, nodeStatus } from "../../stores/connection";
import { openModal } from "../../stores/ui"; import { openModal } from "../../stores/ui";
import * as tauri from "../../lib/tauri";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import Divider from "../common/Divider"; import Divider from "../common/Divider";
@ -21,34 +22,18 @@ const HomeView: Component = () => {
const [searchQuery, setSearchQuery] = createSignal(""); const [searchQuery, setSearchQuery] = createSignal("");
// friends list comes from directory entries marked as friends // friends list comes from directory entries marked as friends
// fall back to dm conversations for peers not yet in the directory
const allPeers = createMemo(() => { const allPeers = createMemo(() => {
const friendList = friends(); const friendList = friends();
const dms = dmConversations(); const onlineSet = onlinePeerIds();
// merge friends from directory with dm conversations return friendList.map((f) => ({
const merged = friendList.map((f) => ({
peer_id: f.peer_id, peer_id: f.peer_id,
display_name: f.display_name, display_name: f.display_name,
bio: f.bio, bio: f.bio,
// check dm conversations for status, default to offline status: (onlineSet.has(f.peer_id) ? "Online" : "Offline") as
status: (dms.find((d) => d.peer_id === f.peer_id)?.status ?? | "Online"
"Offline") as "Online" | "Idle" | "Offline", | "Offline",
})); }));
// also include dm peers that aren't yet in the friends list
for (const dm of dms) {
if (!merged.some((m) => m.peer_id === dm.peer_id)) {
merged.push({
peer_id: dm.peer_id,
display_name: dm.display_name,
bio: "",
status: dm.status,
});
}
}
return merged;
}); });
// directory peers (all known, not just friends) // directory peers (all known, not just friends)
@ -71,11 +56,14 @@ const HomeView: Component = () => {
p.peer_id.toLowerCase().includes(query), p.peer_id.toLowerCase().includes(query),
); );
} }
const onlineSet = onlinePeerIds();
return peers.map((p) => ({ return peers.map((p) => ({
peer_id: p.peer_id, peer_id: p.peer_id,
display_name: p.display_name, display_name: p.display_name,
bio: p.bio, bio: p.bio,
status: "Online" as const, status: (onlineSet.has(p.peer_id) ? "Online" : "Offline") as
| "Online"
| "Offline",
is_friend: p.is_friend, is_friend: p.is_friend,
})); }));
} }
@ -84,7 +72,7 @@ const HomeView: Component = () => {
// filter by tab // filter by tab
if (tab === "online") { if (tab === "online") {
peers = peers.filter((p) => p.status === "Online" || p.status === "Idle"); peers = peers.filter((p) => p.status === "Online");
} }
// filter by search // filter by search
@ -96,22 +84,36 @@ const HomeView: Component = () => {
}); });
const onlineCount = () => const onlineCount = () =>
allPeers().filter((p) => p.status === "Online" || p.status === "Idle") allPeers().filter((p) => p.status === "Online").length;
.length;
function handleOpenDM(peerId: string) { function handleOpenDM(peerId: string) {
// ensure a dm conversation exists for this peer const peer =
const peer = allPeers().find((p) => p.peer_id === peerId); allPeers().find((p) => p.peer_id === peerId) ??
if (peer) { directoryPeers().find((p) => p.peer_id === peerId);
addDMConversation({ if (!peer) return;
peer_id: peer.peer_id,
display_name: peer.display_name, const displayName = peer.display_name;
status: peer.status,
unread_count: 0, // create the conversation on the backend (persists + subscribes to topic)
tauri
.openDMConversation(peerId, displayName)
.then((meta) => {
addDMConversation(meta);
setActiveDM(peerId);
clearDMUnread(peerId);
})
.catch((e) => {
console.error("failed to open dm conversation:", e);
// fallback: still open the conversation locally
addDMConversation({
peer_id: peerId,
display_name: displayName,
last_message: null,
last_message_time: null,
unread_count: 0,
});
setActiveDM(peerId);
}); });
}
setActiveDM(peerId);
clearDMUnread(peerId);
} }
const tabs: { id: FriendsTab; label: string }[] = [ const tabs: { id: FriendsTab; label: string }[] = [
@ -259,11 +261,7 @@ const HomeView: Component = () => {
</p> </p>
</Show> </Show>
<p class="text-[13px] font-mono text-white/40 lowercase"> <p class="text-[13px] font-mono text-white/40 lowercase">
{peer.status === "Online" {peer.status === "Online" ? "online" : "offline"}
? "online"
: peer.status === "Idle"
? "idle"
: "offline"}
</p> </p>
</div> </div>
</div> </div>

View File

@ -4,10 +4,17 @@ import { activeCommunityId } from "../../stores/communities";
import { identity } from "../../stores/identity"; import { identity } from "../../stores/identity";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import SidebarLayout from "../common/SidebarLayout"; import SidebarLayout from "../common/SidebarLayout";
import { openProfileCard } from "../../stores/ui";
import * as tauri from "../../lib/tauri"; import * as tauri from "../../lib/tauri";
const UserSidebar: Component = () => { const UserSidebar: Component = () => {
const [contextMenu, setContextMenu] = createSignal<{ x: number; y: number; memberId: string; memberName: string; memberRoles: string[] } | null>(null); const [contextMenu, setContextMenu] = createSignal<{
x: number;
y: number;
memberId: string;
memberName: string;
memberRoles: string[];
} | null>(null);
const groupedMembers = createMemo(() => { const groupedMembers = createMemo(() => {
const memberList = members(); const memberList = members();
@ -27,7 +34,10 @@ const UserSidebar: Component = () => {
const currentUser = () => identity(); const currentUser = () => identity();
const currentCommunityId = () => activeCommunityId(); const currentCommunityId = () => activeCommunityId();
function handleContextMenu(e: MouseEvent, member: { peer_id: string; display_name: string; roles: string[] }) { function handleContextMenu(
e: MouseEvent,
member: { peer_id: string; display_name: string; roles: string[] },
) {
e.preventDefault(); e.preventDefault();
setContextMenu({ setContextMenu({
x: e.clientX, x: e.clientX,
@ -51,7 +61,9 @@ const UserSidebar: Component = () => {
if (!user) return; if (!user) return;
const currentMember = members().find((m) => m.peer_id === user.peer_id); const currentMember = members().find((m) => m.peer_id === user.peer_id);
const isAdmin = currentMember?.roles.some((r) => r === "admin" || r === "owner"); const isAdmin = currentMember?.roles.some(
(r) => r === "admin" || r === "owner",
);
if (!isAdmin) { if (!isAdmin) {
console.error("not authorized to kick members"); console.error("not authorized to kick members");
@ -93,6 +105,14 @@ const UserSidebar: Component = () => {
<button <button
type="button" type="button"
class="flex items-center gap-3 w-full h-10 px-4 text-left hover:bg-gray-800 transition-colors duration-200 cursor-pointer group" class="flex items-center gap-3 w-full h-10 px-4 text-left hover:bg-gray-800 transition-colors duration-200 cursor-pointer group"
onClick={(e) => {
openProfileCard({
peerId: member.peer_id,
displayName: member.display_name,
anchorX: e.clientX,
anchorY: e.clientY,
});
}}
onContextMenu={(e) => handleContextMenu(e, member)} onContextMenu={(e) => handleContextMenu(e, member)}
> >
<Avatar <Avatar
@ -126,9 +146,16 @@ const UserSidebar: Component = () => {
<Show when={contextMenu()}> <Show when={contextMenu()}>
{(menu) => { {(menu) => {
const user = currentUser(); const user = currentUser();
const currentMember = user ? members().find((m) => m.peer_id === user.peer_id) : null; const currentMember = user
const isAdmin = currentMember?.roles.some((r) => r === "admin" || r === "owner"); ? members().find((m) => m.peer_id === user.peer_id)
const canKick = isAdmin && !menu().memberRoles.includes("owner") && menu().memberId !== user?.peer_id; : null;
const isAdmin = currentMember?.roles.some(
(r) => r === "admin" || r === "owner",
);
const canKick =
isAdmin &&
!menu().memberRoles.includes("owner") &&
menu().memberId !== user?.peer_id;
return ( return (
<div <div

View File

@ -0,0 +1,128 @@
import type { Component } from "solid-js";
import { Show, For } from "solid-js";
import {
voiceParticipants,
localMediaState,
localStream,
remoteStreams,
voiceConnectionState,
voiceError,
joinVoice,
} from "../../stores/voice";
import { identity } from "../../stores/identity";
import VoiceControls from "./VoiceControls";
import VoiceParticipantTile from "./VoiceParticipantTile";
interface VoiceChannelProps {
communityId: string;
channelId: string;
}
// standalone voice channel view for when video/screen share is active
// voice joining is handled by clicking the channel in the sidebar,
// not by mounting this component
const VoiceChannel: Component<VoiceChannelProps> = (props) => {
// get local participant info
const localPeerId = () => identity()?.peer_id;
const localDisplayName = () => identity()?.display_name ?? "You";
// build list of all participants including local user
const allParticipants = () => {
const participants = voiceParticipants();
const localId = localPeerId();
// filter out local user from remote participants list
const remoteParticipants = participants.filter(
(p) => p.peer_id !== localId,
);
// build local participant entry
const localParticipant = {
peer_id: localId ?? "local",
display_name: localDisplayName(),
media_state: localMediaState(),
stream: localStream(),
is_local: true,
};
// build remote participant entries with their streams
const remoteEntries = remoteParticipants.map((p) => ({
peer_id: p.peer_id,
display_name: p.display_name,
media_state: p.media_state,
stream: remoteStreams().get(p.peer_id) ?? null,
is_local: false,
}));
return [localParticipant, ...remoteEntries];
};
const participantCount = () => {
return allParticipants().length;
};
return (
<div class="flex flex-col h-full bg-black">
<div class="flex-1 overflow-auto p-4">
<div class="mb-4">
<h2 class="text-white text-lg font-semibold">Voice Channel</h2>
<p class="text-white/60 text-sm">
{participantCount()} participant
{participantCount() !== 1 ? "s" : ""}
</p>
</div>
{/* error state */}
<Show when={voiceConnectionState() === "error"}>
<div class="flex flex-col items-center justify-center h-64 gap-4">
<div class="text-white/60 text-center">
<p class="text-sm text-red-400 mb-2">
failed to connect to voice channel
</p>
<p class="text-xs text-white/40">{voiceError()}</p>
</div>
<button
type="button"
class="px-4 py-2 text-sm text-white/80 border border-white/20 hover:border-orange hover:text-white transition-colors duration-200 cursor-pointer"
onClick={() => joinVoice(props.communityId, props.channelId)}
>
retry
</button>
</div>
</Show>
{/* connecting state */}
<Show when={voiceConnectionState() === "connecting"}>
<div class="flex items-center justify-center h-64">
<div class="text-white/60 text-center">
<p class="text-sm">connecting to voice channel...</p>
</div>
</div>
</Show>
{/* connected state with participants grid */}
<Show when={voiceConnectionState() === "connected"}>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<For each={allParticipants()}>
{(participant) => (
<VoiceParticipantTile
peer_id={participant.peer_id}
display_name={participant.display_name}
media_state={participant.media_state}
stream={participant.stream}
is_local={participant.is_local}
/>
)}
</For>
</div>
</Show>
</div>
<Show when={voiceConnectionState() === "connected"}>
<VoiceControls />
</Show>
</div>
);
};
export default VoiceChannel;

View File

@ -0,0 +1,99 @@
import type { Component } from "solid-js";
import { Show } from "solid-js";
import { Mic, MicOff, Volume2, VolumeX, Video, VideoOff, Monitor, MonitorOff, PhoneOff } from "lucide-solid";
import IconButton from "../common/IconButton";
import { localMediaState, toggleMute, toggleDeafen, toggleVideo, toggleScreenShare, leaveVoice } from "../../stores/voice";
interface VoiceControlsProps {
onMuteToggle?: () => void;
onDeafenToggle?: () => void;
onVideoToggle?: () => void;
onScreenShareToggle?: () => void;
onLeave?: () => void;
}
const VoiceControls: Component<VoiceControlsProps> = (props) => {
const handleMuteToggle = async () => {
await toggleMute();
props.onMuteToggle?.();
};
const handleDeafenToggle = async () => {
await toggleDeafen();
props.onDeafenToggle?.();
};
const handleVideoToggle = async () => {
await toggleVideo();
props.onVideoToggle?.();
};
const handleScreenShareToggle = async () => {
await toggleScreenShare();
props.onScreenShareToggle?.();
};
const handleLeave = async () => {
await leaveVoice();
props.onLeave?.();
};
return (
<div class="flex items-center justify-center gap-2 p-4 bg-black border-t border-white/10">
<IconButton
label={localMediaState().muted ? "Unmute" : "Mute"}
size={40}
active={localMediaState().muted}
onClick={handleMuteToggle}
>
<Show when={localMediaState().muted} fallback={<Mic size={20} />}>
<MicOff size={20} />
</Show>
</IconButton>
<IconButton
label={localMediaState().deafened ? "Undeafen" : "Deafen"}
size={40}
active={localMediaState().deafened}
onClick={handleDeafenToggle}
>
<Show when={localMediaState().deafened} fallback={<Volume2 size={20} />}>
<VolumeX size={20} />
</Show>
</IconButton>
<IconButton
label={localMediaState().video_enabled ? "Stop Video" : "Start Video"}
size={40}
active={localMediaState().video_enabled}
onClick={handleVideoToggle}
>
<Show when={localMediaState().video_enabled} fallback={<Video size={20} />}>
<VideoOff size={20} />
</Show>
</IconButton>
<IconButton
label={localMediaState().screen_sharing ? "Stop Screen Share" : "Start Screen Share"}
size={40}
active={localMediaState().screen_sharing}
onClick={handleScreenShareToggle}
>
<Show when={localMediaState().screen_sharing} fallback={<Monitor size={20} />}>
<MonitorOff size={20} />
</Show>
</IconButton>
<IconButton
label="Leave Voice Channel"
size={40}
onClick={handleLeave}
class="bg-error hover:bg-red-600"
>
<PhoneOff size={20} />
</IconButton>
</div>
);
};
export default VoiceControls;

View File

@ -0,0 +1,99 @@
import type { Component } from "solid-js";
import { Show, createEffect, onCleanup } from "solid-js";
import { MicOff, VolumeX } from "lucide-solid";
import Avatar from "../common/Avatar";
import { openProfileCard } from "../../stores/ui";
import type { VoiceMediaState } from "../../lib/types";
interface VoiceParticipantTileProps {
peer_id: string;
display_name: string;
media_state: VoiceMediaState;
stream?: MediaStream | null;
is_local?: boolean;
}
const VoiceParticipantTile: Component<VoiceParticipantTileProps> = (props) => {
let videoRef: HTMLVideoElement | undefined;
// attach stream to video element when it changes
createEffect(() => {
const currentStream = props.stream;
if (videoRef && currentStream) {
videoRef.srcObject = currentStream;
}
});
// cleanup video element on unmount
onCleanup(() => {
if (videoRef) {
videoRef.srcObject = null;
}
});
const hasVideo = () => {
return (
props.stream &&
(props.media_state.video_enabled || props.media_state.screen_sharing)
);
};
return (
<div
class="relative bg-black border border-white/10 aspect-video flex items-center justify-center overflow-hidden cursor-pointer"
onClick={(e) => {
openProfileCard({
peerId: props.peer_id,
displayName: props.display_name,
anchorX: e.clientX,
anchorY: e.clientY,
});
}}
>
<Show
when={hasVideo()}
fallback={
<div class="flex flex-col items-center justify-center gap-2 p-4">
<Avatar name={props.display_name} size="xl" />
<span class="text-white text-sm font-medium truncate max-w-full">
{props.display_name}
</span>
</div>
}
>
<video
ref={videoRef}
autoplay
playsinline
muted={props.is_local}
class="w-full h-full object-cover"
/>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2">
<span class="text-white text-sm font-medium truncate">
{props.display_name}
</span>
</div>
</Show>
<Show when={props.media_state.muted}>
<div class="absolute top-2 right-2 bg-black/80 p-1">
<MicOff size={16} class="text-[#FF4F00]" />
</div>
</Show>
<Show when={props.media_state.deafened}>
<div class="absolute top-2 right-2 bg-black/80 p-1">
<VolumeX size={16} class="text-[#FF4F00]" />
</div>
</Show>
<Show when={props.is_local}>
<div class="absolute top-2 left-2 bg-black/80 px-2 py-1">
<span class="text-white/60 text-xs">You</span>
</div>
</Show>
</div>
);
};
export default VoiceParticipantTile;

View File

@ -4,11 +4,17 @@ import type {
PublicIdentity, PublicIdentity,
CommunityMeta, CommunityMeta,
ChannelMeta, ChannelMeta,
CategoryMeta,
ChatMessage, ChatMessage,
Member, Member,
DuskEvent, DuskEvent,
UserSettings, UserSettings,
DirectoryEntry, DirectoryEntry,
ChallengeExport,
VoiceParticipant,
VoiceMediaState,
DirectMessage,
DMConversationMeta,
} from "./types"; } from "./types";
// -- identity -- // -- identity --
@ -24,8 +30,9 @@ export async function loadIdentity(): Promise<PublicIdentity | null> {
export async function createIdentity( export async function createIdentity(
displayName: string, displayName: string,
bio?: string, bio?: string,
challengeData?: ChallengeExport,
): Promise<PublicIdentity> { ): Promise<PublicIdentity> {
return invoke("create_identity", { displayName, bio }); return invoke("create_identity", { displayName, bio, challengeData });
} }
export async function updateDisplayName(name: string): Promise<void> { export async function updateDisplayName(name: string): Promise<void> {
@ -88,14 +95,42 @@ export async function createChannel(
communityId: string, communityId: string,
name: string, name: string,
topic: string, topic: string,
kind?: string,
categoryId?: string | null,
): Promise<ChannelMeta> { ): Promise<ChannelMeta> {
return invoke("create_channel", { communityId, name, topic }); return invoke("create_channel", {
communityId,
name,
topic,
kind,
categoryId,
});
} }
export async function getChannels(communityId: string): Promise<ChannelMeta[]> { export async function getChannels(communityId: string): Promise<ChannelMeta[]> {
return invoke("get_channels", { communityId }); return invoke("get_channels", { communityId });
} }
export async function createCategory(
communityId: string,
name: string,
): Promise<CategoryMeta> {
return invoke("create_category", { communityId, name });
}
export async function getCategories(
communityId: string,
): Promise<CategoryMeta[]> {
return invoke("get_categories", { communityId });
}
export async function reorderChannels(
communityId: string,
channelIds: string[],
): Promise<ChannelMeta[]> {
return invoke("reorder_channels", { communityId, channelIds });
}
// -- messages -- // -- messages --
export async function sendMessage( export async function sendMessage(
@ -171,6 +206,12 @@ export async function resetIdentity(): Promise<void> {
return invoke("reset_identity"); return invoke("reset_identity");
} }
// -- connectivity --
export async function checkInternetConnectivity(): Promise<boolean> {
return invoke("check_internet_connectivity");
}
// -- events -- // -- events --
export function onDuskEvent( export function onDuskEvent(
@ -178,3 +219,112 @@ export function onDuskEvent(
): Promise<UnlistenFn> { ): Promise<UnlistenFn> {
return listen<DuskEvent>("dusk-event", (e) => callback(e.payload)); return listen<DuskEvent>("dusk-event", (e) => callback(e.payload));
} }
// -- voice --
export async function joinVoiceChannel(
communityId: string,
channelId: string,
): Promise<VoiceParticipant[]> {
return invoke("join_voice_channel", { communityId, channelId });
}
export async function leaveVoiceChannel(
communityId: string,
channelId: string,
): Promise<void> {
return invoke("leave_voice_channel", { communityId, channelId });
}
export async function updateVoiceMediaState(
communityId: string,
channelId: string,
mediaState: VoiceMediaState,
): Promise<void> {
return invoke("update_voice_media_state", {
communityId,
channelId,
mediaState,
});
}
export async function sendVoiceSdp(
communityId: string,
channelId: string,
toPeer: string,
sdpType: string,
sdp: string,
): Promise<void> {
return invoke("send_voice_sdp", {
communityId,
channelId,
toPeer,
sdpType,
sdp,
});
}
export async function sendVoiceIceCandidate(
communityId: string,
channelId: string,
toPeer: string,
candidate: string,
sdpMid: string | null,
sdpMlineIndex: number | null,
): Promise<void> {
return invoke("send_voice_ice_candidate", {
communityId,
channelId,
toPeer,
candidate,
sdpMid,
sdpMlineIndex,
});
}
export async function getVoiceParticipants(
communityId: string,
channelId: string,
): Promise<VoiceParticipant[]> {
return invoke("get_voice_participants", { communityId, channelId });
}
// -- direct messages --
export async function sendDM(
peerId: string,
content: string,
): Promise<DirectMessage> {
return invoke("send_dm", { peerId, content });
}
export async function getDMMessages(
peerId: string,
before?: number,
limit?: number,
): Promise<DirectMessage[]> {
return invoke("get_dm_messages", { peerId, before, limit });
}
export async function getDMConversations(): Promise<DMConversationMeta[]> {
return invoke("get_dm_conversations");
}
export async function markDMRead(peerId: string): Promise<void> {
return invoke("mark_dm_read", { peerId });
}
export async function deleteDMConversation(peerId: string): Promise<void> {
return invoke("delete_dm_conversation", { peerId });
}
export async function sendDMTyping(peerId: string): Promise<void> {
return invoke("send_dm_typing", { peerId });
}
export async function openDMConversation(
peerId: string,
displayName: string,
): Promise<DMConversationMeta> {
return invoke("open_dm_conversation", { peerId, displayName });
}

View File

@ -1,12 +1,48 @@
// shared type definitions mirroring the rust structs // shared type definitions mirroring the rust structs
// this is the single source of truth for the frontend-backend contract // this is the single source of truth for the frontend-backend contract
export interface VerificationProof {
metrics_hash: string;
signature: string;
timestamp: number;
score: number;
}
export interface PublicIdentity { export interface PublicIdentity {
peer_id: string; peer_id: string;
display_name: string; display_name: string;
public_key: string; public_key: string;
bio: string; bio: string;
created_at: number; created_at: number;
verification_proof?: VerificationProof;
}
// raw challenge data sent to the rust backend for server-side analysis
export interface MouseSampleExport {
x: number;
y: number;
t: number;
}
export interface SegmentExport {
fromTarget: number;
toTarget: number;
samples: MouseSampleExport[];
clickTime: number;
startTime: number;
}
export interface TargetCircleExport {
id: number;
x: number;
y: number;
}
export interface ChallengeExport {
segments: SegmentExport[];
circles: TargetCircleExport[];
totalStartTime: number;
totalEndTime: number;
} }
export type UserStatus = "online" | "idle" | "dnd" | "invisible"; export type UserStatus = "online" | "idle" | "dnd" | "invisible";
@ -45,6 +81,16 @@ export interface ChannelMeta {
name: string; name: string;
topic: string; topic: string;
kind: "Text" | "Voice"; kind: "Text" | "Voice";
position: number;
category_id: string | null;
}
// user-defined grouping for channels within a community
export interface CategoryMeta {
id: string;
community_id: string;
name: string;
position: number;
} }
export interface ChatMessage { export interface ChatMessage {
@ -57,6 +103,25 @@ export interface ChatMessage {
edited: boolean; edited: boolean;
} }
// a direct message between two peers
export interface DirectMessage {
id: string;
from_peer: string;
to_peer: string;
from_display_name: string;
content: string;
timestamp: number;
}
// metadata for a persisted dm conversation
export interface DMConversationMeta {
peer_id: string;
display_name: string;
last_message: string | null;
last_message_time: number | null;
unread_count: number;
}
export interface Member { export interface Member {
peer_id: string; peer_id: string;
display_name: string; display_name: string;
@ -82,6 +147,21 @@ export interface DirectoryEntry {
is_friend: boolean; is_friend: boolean;
} }
// media state for a participant in a voice channel
export interface VoiceMediaState {
muted: boolean;
deafened: boolean;
video_enabled: boolean;
screen_sharing: boolean;
}
// a peer currently connected to a voice channel
export interface VoiceParticipant {
peer_id: string;
display_name: string;
media_state: VoiceMediaState;
}
// discriminated union for events emitted from rust // discriminated union for events emitted from rust
export type DuskEvent = export type DuskEvent =
| { kind: "message_received"; payload: ChatMessage } | { kind: "message_received"; payload: ChatMessage }
@ -96,4 +176,55 @@ export type DuskEvent =
kind: "profile_received"; kind: "profile_received";
payload: { peer_id: string; display_name: string; bio: string }; payload: { peer_id: string; display_name: string; bio: string };
} }
| { kind: "profile_revoked"; payload: { peer_id: string } }; | { kind: "profile_revoked"; payload: { peer_id: string } }
| { kind: "relay_status"; payload: { connected: boolean } }
| {
kind: "voice_participant_joined";
payload: {
community_id: string;
channel_id: string;
peer_id: string;
display_name: string;
media_state: VoiceMediaState;
};
}
| {
kind: "voice_participant_left";
payload: {
community_id: string;
channel_id: string;
peer_id: string;
};
}
| {
kind: "voice_media_state_changed";
payload: {
community_id: string;
channel_id: string;
peer_id: string;
media_state: VoiceMediaState;
};
}
| {
kind: "voice_sdp_received";
payload: {
community_id: string;
channel_id: string;
from_peer: string;
sdp_type: string;
sdp: string;
};
}
| {
kind: "voice_ice_candidate_received";
payload: {
community_id: string;
channel_id: string;
from_peer: string;
candidate: string;
sdp_mid: string | null;
sdp_mline_index: number | null;
};
}
| { kind: "dm_received"; payload: DirectMessage }
| { kind: "dm_typing"; payload: { peer_id: string } };

246
src/lib/webrtc.ts Normal file
View File

@ -0,0 +1,246 @@
// webrtc peer connection manager for voice/video calls
// manages one RTCPeerConnection per remote peer in a full mesh topology
// this is a utility module with no signals - the voice store drives it
// no external stun/turn servers for now, rely on host candidates only
// this works for LAN peers and peers on the same network segment
const rtcConfig: RTCConfiguration = {
iceServers: [],
};
export class PeerConnectionManager {
private connections: Map<string, RTCPeerConnection> = new Map();
private localStream: MediaStream | null = null;
private screenStream: MediaStream | null = null;
// the local peer id, used for glare resolution during simultaneous offers
private localPeerId: string | null = null;
// callbacks set by the voice store to bridge webrtc events into reactive state
onRemoteStream: ((peerId: string, stream: MediaStream) => void) | null = null;
onRemoteStreamRemoved: ((peerId: string) => void) | null = null;
onIceCandidate: ((peerId: string, candidate: RTCIceCandidate) => void) | null = null;
onNegotiationNeeded: ((peerId: string) => void) | null = null;
setLocalPeerId(peerId: string): void {
this.localPeerId = peerId;
}
setLocalStream(stream: MediaStream | null): void {
this.localStream = stream;
}
setScreenStream(stream: MediaStream | null): void {
this.screenStream = stream;
}
// create a new peer connection for a remote peer
// uses lexicographic peer_id comparison for glare resolution:
// the peer with the smaller id is always the offerer
createConnection(peerId: string): RTCPeerConnection {
// close any existing connection to this peer before creating a new one
this.closeConnection(peerId);
const pc = new RTCPeerConnection(rtcConfig);
this.connections.set(peerId, pc);
// add all local tracks to the new connection
if (this.localStream) {
for (const track of this.localStream.getTracks()) {
pc.addTrack(track, this.localStream);
}
}
if (this.screenStream) {
for (const track of this.screenStream.getTracks()) {
pc.addTrack(track, this.screenStream);
}
}
// wire up event handlers
pc.onicecandidate = (event) => {
if (event.candidate && this.onIceCandidate) {
this.onIceCandidate(peerId, event.candidate);
}
};
pc.ontrack = (event) => {
if (event.streams.length > 0 && this.onRemoteStream) {
this.onRemoteStream(peerId, event.streams[0]);
}
};
pc.onnegotiationneeded = () => {
if (this.onNegotiationNeeded) {
this.onNegotiationNeeded(peerId);
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
if (this.onRemoteStreamRemoved) {
this.onRemoteStreamRemoved(peerId);
}
}
};
return pc;
}
// determine if we should be the offerer based on lexicographic peer_id comparison
shouldOffer(remotePeerId: string): boolean {
if (!this.localPeerId) return false;
return this.localPeerId < remotePeerId;
}
async createOffer(peerId: string): Promise<RTCSessionDescriptionInit> {
const pc = this.connections.get(peerId);
if (!pc) {
throw new Error(`no connection for peer ${peerId}`);
}
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
return offer;
} catch (err) {
console.error(`failed to create offer for peer ${peerId}:`, err);
throw err;
}
}
async createAnswer(
peerId: string,
offer: RTCSessionDescriptionInit,
): Promise<RTCSessionDescriptionInit> {
const pc = this.connections.get(peerId);
if (!pc) {
throw new Error(`no connection for peer ${peerId}`);
}
try {
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
return answer;
} catch (err) {
console.error(`failed to create answer for peer ${peerId}:`, err);
throw err;
}
}
async setRemoteAnswer(
peerId: string,
answer: RTCSessionDescriptionInit,
): Promise<void> {
const pc = this.connections.get(peerId);
if (!pc) {
throw new Error(`no connection for peer ${peerId}`);
}
try {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
} catch (err) {
console.error(`failed to set remote answer for peer ${peerId}:`, err);
throw err;
}
}
async addIceCandidate(
peerId: string,
candidate: RTCIceCandidateInit,
): Promise<void> {
const pc = this.connections.get(peerId);
if (!pc) {
// candidate arrived before connection was created, safe to ignore
return;
}
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (err) {
// ice candidates can arrive out of order or for stale connections
console.error(`failed to add ice candidate for peer ${peerId}:`, err);
}
}
closeConnection(peerId: string): void {
const pc = this.connections.get(peerId);
if (pc) {
pc.onicecandidate = null;
pc.ontrack = null;
pc.onnegotiationneeded = null;
pc.onconnectionstatechange = null;
pc.close();
this.connections.delete(peerId);
}
}
closeAll(): void {
for (const [peerId] of this.connections) {
this.closeConnection(peerId);
}
this.connections.clear();
this.localStream = null;
this.screenStream = null;
}
getConnection(peerId: string): RTCPeerConnection | undefined {
return this.connections.get(peerId);
}
// replaces tracks on all existing connections
// used when toggling video or screen share mid-call
updateTracks(): void {
for (const [, pc] of this.connections) {
const senders = pc.getSenders();
// build the set of tracks we want active on each connection
const desiredTracks: MediaStreamTrack[] = [];
if (this.localStream) {
desiredTracks.push(...this.localStream.getTracks());
}
if (this.screenStream) {
desiredTracks.push(...this.screenStream.getTracks());
}
// replace or add tracks that should be present
for (const track of desiredTracks) {
const existingSender = senders.find(
(s) => s.track?.kind === track.kind && s.track?.id === track.id,
);
if (!existingSender) {
// check if there is a sender with the same kind we can replace
const kindSender = senders.find(
(s) => s.track?.kind === track.kind || (!s.track && true),
);
if (kindSender) {
kindSender.replaceTrack(track).catch((err) => {
console.error("failed to replace track:", err);
});
} else {
// no existing sender for this kind, add a new one
const stream = track.kind === "video" && this.screenStream?.getVideoTracks().includes(track)
? this.screenStream
: this.localStream;
if (stream) {
pc.addTrack(track, stream);
}
}
}
}
// remove senders whose tracks are no longer desired
const desiredTrackIds = new Set(desiredTracks.map((t) => t.id));
for (const sender of senders) {
if (sender.track && !desiredTrackIds.has(sender.track.id)) {
try {
pc.removeTrack(sender);
} catch (err) {
console.error("failed to remove track:", err);
}
}
}
}
}
}

View File

@ -1,7 +1,10 @@
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import type { ChannelMeta } from "../lib/types"; import type { ChannelMeta, CategoryMeta } from "../lib/types";
import { reorderChannels as reorderChannelsCall } from "../lib/tauri";
import { activeCommunityId } from "./communities";
const [channels, setChannels] = createSignal<ChannelMeta[]>([]); const [channels, setChannels] = createSignal<ChannelMeta[]>([]);
const [categories, setCategories] = createSignal<CategoryMeta[]>([]);
const [activeChannelId, setActiveChannelId] = createSignal<string | null>(null); const [activeChannelId, setActiveChannelId] = createSignal<string | null>(null);
export function setActiveChannel(id: string | null) { export function setActiveChannel(id: string | null) {
@ -12,4 +15,20 @@ export function activeChannel(): ChannelMeta | undefined {
return channels().find((c) => c.id === activeChannelId()); return channels().find((c) => c.id === activeChannelId());
} }
export { channels, activeChannelId, setChannels }; export function addCategory(category: CategoryMeta) {
setCategories((prev) => [...prev, category]);
}
export async function reorderChannels(channelIds: string[]): Promise<void> {
const communityId = activeCommunityId();
if (!communityId) return;
try {
const updated = await reorderChannelsCall(communityId, channelIds);
setChannels(updated);
} catch (error) {
console.error("failed to reorder channels:", error);
}
}
export { channels, categories, activeChannelId, setChannels, setCategories };

View File

@ -5,6 +5,7 @@ const [peerCount, setPeerCount] = createSignal(0);
const [nodeStatus, setNodeStatus] = createSignal< const [nodeStatus, setNodeStatus] = createSignal<
"starting" | "running" | "stopped" | "error" "starting" | "running" | "stopped" | "error"
>("stopped"); >("stopped");
const [relayConnected, setRelayConnected] = createSignal(true);
export { export {
isConnected, isConnected,
@ -13,4 +14,6 @@ export {
setPeerCount, setPeerCount,
nodeStatus, nodeStatus,
setNodeStatus, setNodeStatus,
relayConnected,
setRelayConnected,
}; };

View File

@ -1,33 +1,25 @@
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import type { ChatMessage } from "../lib/types"; import type { DirectMessage, DMConversationMeta } from "../lib/types";
// represents a direct message conversation with a peer // dm conversations loaded from disk via tauri backend
export interface DMConversation { const [dmConversations, setDMConversations] = createSignal<
peer_id: string; DMConversationMeta[]
display_name: string; >([]);
status: "Online" | "Idle" | "Offline";
last_message?: string;
last_message_time?: number;
unread_count: number;
}
const [dmConversations, setDMConversations] = createSignal<DMConversation[]>(
[],
);
const [activeDMPeerId, setActiveDMPeerId] = createSignal<string | null>(null); const [activeDMPeerId, setActiveDMPeerId] = createSignal<string | null>(null);
const [dmMessages, setDMMessages] = createSignal<ChatMessage[]>([]); const [dmMessages, setDMMessages] = createSignal<DirectMessage[]>([]);
// peers currently typing in the active dm
const [dmTypingPeers, setDMTypingPeers] = createSignal<string[]>([]);
export function setActiveDM(peerId: string | null) { export function setActiveDM(peerId: string | null) {
setActiveDMPeerId(peerId); setActiveDMPeerId(peerId);
} }
export function activeDMConversation(): DMConversation | undefined { export function activeDMConversation(): DMConversationMeta | undefined {
return dmConversations().find((dm) => dm.peer_id === activeDMPeerId()); return dmConversations().find((dm) => dm.peer_id === activeDMPeerId());
} }
export function addDMConversation(dm: DMConversation) { export function addDMConversation(dm: DMConversationMeta) {
setDMConversations((prev) => { setDMConversations((prev) => {
// avoid duplicates
if (prev.some((existing) => existing.peer_id === dm.peer_id)) return prev; if (prev.some((existing) => existing.peer_id === dm.peer_id)) return prev;
return [...prev, dm]; return [...prev, dm];
}); });
@ -68,7 +60,7 @@ export function clearDMUnread(peerId: string) {
); );
} }
export function addDMMessage(message: ChatMessage) { export function addDMMessage(message: DirectMessage) {
setDMMessages((prev) => [...prev, message]); setDMMessages((prev) => [...prev, message]);
} }
@ -76,10 +68,78 @@ export function clearDMMessages() {
setDMMessages([]); setDMMessages([]);
} }
// handle an incoming dm from the network
export function handleIncomingDM(message: DirectMessage) {
const active = activeDMPeerId();
// if the conversation is currently active, add the message to the view
if (active === message.from_peer) {
addDMMessage(message);
}
// update or create the conversation entry
const existing = dmConversations().find(
(dm) => dm.peer_id === message.from_peer,
);
if (existing) {
updateDMLastMessage(message.from_peer, message.content, message.timestamp);
// only increment unread if this conversation is not active
if (active !== message.from_peer) {
incrementDMUnread(message.from_peer);
}
} else {
// new conversation from a peer we haven't talked to before
addDMConversation({
peer_id: message.from_peer,
display_name: message.from_display_name,
last_message: message.content,
last_message_time: message.timestamp,
unread_count: active === message.from_peer ? 0 : 1,
});
}
}
// add a typing peer indicator with auto-expiry
let dmTypingTimers: Record<string, ReturnType<typeof setTimeout>> = {};
export function addDMTypingPeer(peerId: string) {
setDMTypingPeers((prev) =>
prev.includes(peerId) ? prev : [...prev, peerId],
);
// clear any existing timer for this peer
if (dmTypingTimers[peerId]) {
clearTimeout(dmTypingTimers[peerId]);
}
// auto-remove after 3 seconds
dmTypingTimers[peerId] = setTimeout(() => {
setDMTypingPeers((prev) => prev.filter((id) => id !== peerId));
delete dmTypingTimers[peerId];
}, 3000);
}
export function clearDMTypingPeers() {
setDMTypingPeers([]);
for (const timer of Object.values(dmTypingTimers)) {
clearTimeout(timer);
}
dmTypingTimers = {};
}
// update a conversation's display name when we get a profile update
export function updateDMPeerDisplayName(peerId: string, displayName: string) {
setDMConversations((prev) =>
prev.map((dm) =>
dm.peer_id === peerId ? { ...dm, display_name: displayName } : dm,
),
);
}
export { export {
dmConversations, dmConversations,
activeDMPeerId, activeDMPeerId,
dmMessages, dmMessages,
dmTypingPeers,
setDMConversations, setDMConversations,
setDMMessages, setDMMessages,
}; };

Some files were not shown because too many files have changed in this diff Show More