started implementing core features (profiles, dms, servers, text/voice, settings, etc)
added icons and branding
3
bun.lock
|
|
@ -9,6 +9,7 @@
|
|||
"@fontsource/space-grotesk": "^5.2.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"lucide-solid": "^0.469.0",
|
||||
"motion": "^12.0.0",
|
||||
"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=="],
|
||||
|
||||
"@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__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"@fontsource/space-grotesk": "^5.2.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"lucide-solid": "^0.469.0",
|
||||
"motion": "^12.0.0",
|
||||
"solid-js": "^1.9.3",
|
||||
|
|
|
|||
|
|
@ -1123,6 +1123,7 @@ dependencies = [
|
|||
"tauri-build",
|
||||
"tauri-plugin-shell",
|
||||
"tokio",
|
||||
"webkit2gtk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ libp2p = { version = "0.54", features = [
|
|||
"noise",
|
||||
"yamux",
|
||||
"tcp",
|
||||
"dns",
|
||||
"tokio",
|
||||
"identify",
|
||||
"macros",
|
||||
|
|
@ -56,3 +57,7 @@ dotenvy = "0.15"
|
|||
|
||||
# async utilities
|
||||
futures = "0.3"
|
||||
|
||||
# platform-specific: webview media permissions on linux
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 417 B |
|
After Width: | Height: | Size: 836 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 390 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 596 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 880 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 666 B |
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 330 B |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 748 B |
|
After Width: | Height: | Size: 397 B |
|
After Width: | Height: | Size: 742 B |
|
After Width: | Height: | Size: 742 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 978 B |
|
After Width: | Height: | Size: 978 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 918 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -1,10 +1,13 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tauri::State;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use crate::node::gossip;
|
||||
use crate::node::{self, NodeCommand};
|
||||
use crate::protocol::messages::{ChatMessage, GossipMessage, ProfileAnnouncement, TypingIndicator};
|
||||
use crate::verification;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -19,11 +22,12 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re
|
|||
state.crdt_engine.clone(),
|
||||
state.storage.clone(),
|
||||
app,
|
||||
state.voice_channels.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 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(),
|
||||
display_name: id.display_name.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)
|
||||
.unwrap()
|
||||
.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);
|
||||
|
||||
{
|
||||
|
|
@ -114,6 +122,41 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re
|
|||
.send(NodeCommand::RegisterRendezvous { namespace })
|
||||
.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(())
|
||||
|
|
@ -242,3 +285,28 @@ fn find_community_for_channel(
|
|||
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(_)))))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use tauri::State;
|
|||
|
||||
use crate::node::gossip;
|
||||
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::AppState;
|
||||
|
||||
|
|
@ -224,6 +224,8 @@ pub async fn create_channel(
|
|||
community_id: String,
|
||||
name: String,
|
||||
topic: String,
|
||||
kind: Option<String>,
|
||||
category_id: Option<String>,
|
||||
) -> Result<ChannelMeta, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(community_id.as_bytes());
|
||||
|
|
@ -236,12 +238,19 @@ pub async fn create_channel(
|
|||
let hash = hasher.finalize();
|
||||
let channel_id = format!("ch_{}", &hex::encode(hash)[..12]);
|
||||
|
||||
let channel_kind = match kind.as_deref() {
|
||||
Some("voice") | Some("Voice") => ChannelKind::Voice,
|
||||
_ => ChannelKind::Text,
|
||||
};
|
||||
|
||||
let channel = ChannelMeta {
|
||||
id: channel_id,
|
||||
community_id: community_id.clone(),
|
||||
name,
|
||||
topic,
|
||||
kind: ChannelKind::Text,
|
||||
kind: channel_kind,
|
||||
position: 0,
|
||||
category_id,
|
||||
};
|
||||
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
|
|
@ -278,6 +287,59 @@ pub async fn get_channels(
|
|||
engine.get_channels(&community_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_category(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
name: String,
|
||||
) -> Result<CategoryMeta, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(community_id.as_bytes());
|
||||
hasher.update(name.as_bytes());
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
hasher.update(now.to_le_bytes());
|
||||
let hash = hasher.finalize();
|
||||
let category_id = format!("cat_{}", &hex::encode(hash)[..12]);
|
||||
|
||||
let category = CategoryMeta {
|
||||
id: category_id,
|
||||
community_id: community_id.clone(),
|
||||
name,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
engine.create_category(&community_id, &category)?;
|
||||
drop(engine);
|
||||
|
||||
// broadcast the change via document sync
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let sync_topic = "dusk/sync".to_string();
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: sync_topic,
|
||||
data: community_id.as_bytes().to_vec(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(category)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_categories(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
) -> Result<Vec<CategoryMeta>, String> {
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
engine.get_categories(&community_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_members(
|
||||
state: State<'_, AppState>,
|
||||
|
|
@ -442,3 +504,30 @@ pub async fn generate_invite(
|
|||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -5,10 +5,44 @@ use tauri::State;
|
|||
use crate::node::gossip;
|
||||
use crate::node::NodeCommand;
|
||||
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::verification::{self, ChallengeSubmission};
|
||||
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]
|
||||
pub async fn has_identity(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
Ok(state.storage.has_identity())
|
||||
|
|
@ -37,8 +71,30 @@ pub async fn create_identity(
|
|||
state: State<'_, AppState>,
|
||||
display_name: String,
|
||||
bio: Option<String>,
|
||||
challenge_data: Option<ChallengeSubmission>,
|
||||
) -> 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)?;
|
||||
|
||||
// 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.save(&state.storage)?;
|
||||
|
||||
announce_profile(id, &state).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +138,12 @@ pub async fn update_profile(
|
|||
id.bio = bio;
|
||||
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]
|
||||
|
|
@ -98,13 +161,23 @@ pub async fn save_settings(
|
|||
) -> Result<(), String> {
|
||||
// also update the identity display name if it changed
|
||||
let mut identity = state.identity.lock().await;
|
||||
let mut name_changed = false;
|
||||
if let Some(id) = identity.as_mut() {
|
||||
if id.display_name != settings.display_name {
|
||||
id.display_name = settings.display_name.clone();
|
||||
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
|
||||
.storage
|
||||
.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")?;
|
||||
|
||||
// build the revocation message before we destroy the identity
|
||||
let revocation = ProfileRevocation {
|
||||
let mut revocation = ProfileRevocation {
|
||||
peer_id: id.peer_id.to_string(),
|
||||
public_key: hex::encode(id.keypair.public().encode_protobuf()),
|
||||
timestamp: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64,
|
||||
signature: String::new(),
|
||||
};
|
||||
revocation.signature = verification::sign_revocation(&id.keypair, &revocation);
|
||||
|
||||
// broadcast revocation on the directory gossip topic
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
pub mod chat;
|
||||
pub mod community;
|
||||
pub mod dm;
|
||||
pub mod identity;
|
||||
pub mod voice;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use automerge::{transaction::Transactable, AutoCommit, ObjType, ReadDoc, ROOT};
|
||||
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;
|
||||
|
||||
// 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)?;
|
||||
|
||||
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 _roles = doc.put_object(ROOT, "roles", ObjType::Map)?;
|
||||
|
||||
// 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)?;
|
||||
doc.put(&general, "name", "general")?;
|
||||
doc.put(&general, "topic", "general discussion")?;
|
||||
doc.put(&general, "kind", "text")?;
|
||||
doc.put(&general, "position", 0i64)?;
|
||||
let _messages = doc.put_object(&general, "messages", ObjType::List)?;
|
||||
|
||||
// add the creator as the first member with owner role
|
||||
|
|
@ -55,6 +60,23 @@ pub fn add_channel(
|
|||
.map(|(_, id)| id)
|
||||
.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)?;
|
||||
doc.put(&ch, "name", channel.name.as_str())?;
|
||||
doc.put(&ch, "topic", channel.topic.as_str())?;
|
||||
|
|
@ -66,11 +88,93 @@ pub fn add_channel(
|
|||
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)?;
|
||||
|
||||
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
|
||||
pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelMeta>, String> {
|
||||
let channels_obj = doc
|
||||
|
|
@ -96,6 +200,8 @@ pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelM
|
|||
"voice" => ChannelKind::Voice,
|
||||
_ => 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 {
|
||||
id: key.to_string(),
|
||||
|
|
@ -103,10 +209,15 @@ pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelM
|
|||
name,
|
||||
topic,
|
||||
kind,
|
||||
position,
|
||||
category_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// sort by position
|
||||
result.sort_by_key(|c| c.position);
|
||||
|
||||
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 --
|
||||
|
||||
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")?;
|
||||
|
||||
let keys = doc.keys(&channels_obj);
|
||||
|
||||
|
||||
for channel_key in keys {
|
||||
let ch_obj = doc
|
||||
.get(&channels_obj, &channel_key)
|
||||
|
|
@ -298,7 +436,8 @@ pub fn get_message_by_id(
|
|||
id: id.clone(),
|
||||
channel_id: channel_key.to_string(),
|
||||
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(),
|
||||
timestamp: get_i64(doc, &msg_id, "timestamp").unwrap_or(0) as u64,
|
||||
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
|
||||
pub fn delete_message_by_id(
|
||||
doc: &mut AutoCommit,
|
||||
message_id: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn delete_message_by_id(doc: &mut AutoCommit, message_id: &str) -> Result<(), String> {
|
||||
let channels_obj = doc
|
||||
.get(ROOT, "channels")
|
||||
.map_err(|e| e.to_string())?
|
||||
|
|
@ -326,7 +462,7 @@ pub fn delete_message_by_id(
|
|||
.ok_or("channels key not found")?;
|
||||
|
||||
let keys: Vec<String> = doc.keys(&channels_obj).collect();
|
||||
|
||||
|
||||
for channel_key in keys {
|
||||
let ch_obj = doc
|
||||
.get(&channels_obj, &channel_key)
|
||||
|
|
@ -350,8 +486,7 @@ pub fn delete_message_by_id(
|
|||
if let Some(msg_obj_id) = msg_obj {
|
||||
let id = get_str(doc, &msg_obj_id, "id").unwrap_or_default();
|
||||
if id == message_id {
|
||||
doc.delete(&msgs_id, i)
|
||||
.map_err(|e| e.to_string())?;
|
||||
doc.delete(&msgs_id, i).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
|
@ -364,9 +499,7 @@ pub fn delete_message_by_id(
|
|||
}
|
||||
|
||||
// get all members from the community document
|
||||
pub fn get_members(
|
||||
doc: &AutoCommit,
|
||||
) -> Result<Vec<crate::protocol::community::Member>, String> {
|
||||
pub fn get_members(doc: &AutoCommit) -> Result<Vec<crate::protocol::community::Member>, String> {
|
||||
let members_obj = doc
|
||||
.get(ROOT, "members")
|
||||
.map_err(|e| e.to_string())?
|
||||
|
|
@ -385,7 +518,7 @@ pub fn get_members(
|
|||
if let Some(member_id) = member_obj {
|
||||
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;
|
||||
|
||||
|
||||
// get roles list
|
||||
let roles: Vec<String> = doc
|
||||
.get(&member_id, "roles")
|
||||
|
|
@ -419,10 +552,7 @@ pub fn get_members(
|
|||
}
|
||||
|
||||
// remove a member from the community
|
||||
pub fn remove_member(
|
||||
doc: &mut AutoCommit,
|
||||
peer_id: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn remove_member(doc: &mut AutoCommit, peer_id: &str) -> Result<(), String> {
|
||||
let members_obj = doc
|
||||
.get(ROOT, "members")
|
||||
.map_err(|e| e.to_string())?
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::sync::Arc;
|
|||
|
||||
use automerge::AutoCommit;
|
||||
|
||||
use crate::protocol::community::{ChannelMeta, CommunityMeta};
|
||||
use crate::protocol::community::{CategoryMeta, ChannelMeta, CommunityMeta};
|
||||
use crate::protocol::messages::ChatMessage;
|
||||
use crate::storage::DiskStorage;
|
||||
|
||||
|
|
@ -91,6 +91,50 @@ impl CrdtEngine {
|
|||
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
|
||||
pub fn append_message(
|
||||
&mut self,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,15 @@ mod crdt;
|
|||
mod node;
|
||||
mod protocol;
|
||||
mod storage;
|
||||
mod verification;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::crdt::CrdtEngine;
|
||||
use crate::protocol::identity::DuskIdentity;
|
||||
use crate::protocol::messages::VoiceParticipant;
|
||||
use crate::storage::DiskStorage;
|
||||
|
||||
// shared application state accessible from all tauri commands
|
||||
|
|
@ -17,6 +20,8 @@ pub struct AppState {
|
|||
pub crdt_engine: Arc<Mutex<CrdtEngine>>,
|
||||
pub storage: Arc<DiskStorage>,
|
||||
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 {
|
||||
|
|
@ -36,6 +41,7 @@ impl AppState {
|
|||
crdt_engine,
|
||||
storage,
|
||||
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()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.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![
|
||||
commands::identity::has_identity,
|
||||
commands::identity::load_identity,
|
||||
|
|
@ -67,6 +95,7 @@ pub fn run() {
|
|||
commands::chat::send_typing,
|
||||
commands::chat::start_node,
|
||||
commands::chat::stop_node,
|
||||
commands::chat::check_internet_connectivity,
|
||||
commands::community::create_community,
|
||||
commands::community::join_community,
|
||||
commands::community::leave_community,
|
||||
|
|
@ -77,6 +106,22 @@ pub fn run() {
|
|||
commands::community::delete_message,
|
||||
commands::community::kick_member,
|
||||
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!())
|
||||
.expect("error while running dusk");
|
||||
|
|
|
|||
|
|
@ -32,3 +32,42 @@ pub fn topic_for_directory() -> String {
|
|||
pub fn topic_for_sync() -> 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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ pub mod discovery;
|
|||
pub mod gossip;
|
||||
pub mod swarm;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
use tauri::Emitter;
|
||||
|
|
@ -11,12 +11,11 @@ use tokio::sync::Mutex;
|
|||
|
||||
use crate::crdt::CrdtEngine;
|
||||
use crate::protocol::identity::DirectoryEntry;
|
||||
use crate::verification;
|
||||
|
||||
// default relay address - override with DUSK_RELAY_ADDR env var
|
||||
// format: /ip4/<ip>/tcp/<port>/p2p/<peer_id>
|
||||
// left empty because 0.0.0.0 is a listen address, not a routable dial target.
|
||||
// users must set DUSK_RELAY_ADDR to a reachable relay for WAN connectivity.
|
||||
const DEFAULT_RELAY_ADDR: &str = "";
|
||||
// default public relay - override with DUSK_RELAY_ADDR env var
|
||||
const DEFAULT_RELAY_ADDR: &str =
|
||||
"/dns4/relay.duskchat.app/tcp/4001/p2p/12D3KooWGQkCkACcibJPKzus7Q6U1aYngfTuS4gwYwmJkJJtrSaw";
|
||||
|
||||
// relay reconnection parameters
|
||||
const RELAY_INITIAL_BACKOFF_SECS: u64 = 2;
|
||||
|
|
@ -24,6 +23,9 @@ const RELAY_MAX_BACKOFF_SECS: u64 = 120;
|
|||
const RELAY_BACKOFF_MULTIPLIER: u64 = 2;
|
||||
// max time to hold pending rendezvous registrations before discarding (10 min)
|
||||
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
|
||||
fn relay_addr() -> Option<libp2p::Multiaddr> {
|
||||
|
|
@ -119,6 +121,50 @@ pub enum DuskEvent {
|
|||
},
|
||||
#[serde(rename = "profile_revoked")]
|
||||
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
|
||||
|
|
@ -128,12 +174,17 @@ fn community_id_from_topic(topic: &str) -> Option<&str> {
|
|||
.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
|
||||
pub async fn start(
|
||||
keypair: libp2p::identity::Keypair,
|
||||
crdt_engine: Arc<Mutex<CrdtEngine>>,
|
||||
storage: Arc<crate::storage::DiskStorage>,
|
||||
app_handle: tauri::AppHandle,
|
||||
voice_channels: VoiceChannelMap,
|
||||
) -> Result<NodeHandle, String> {
|
||||
let mut swarm_instance =
|
||||
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);
|
||||
|
||||
// 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 {
|
||||
log::info!("dialing relay at {}", addr);
|
||||
if let Err(e) = swarm_instance.dial(addr.clone()) {
|
||||
|
|
@ -172,6 +226,10 @@ pub async fn start(
|
|||
// track connected peers for accurate count
|
||||
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
|
||||
let mut relay_reservation_active = false;
|
||||
|
||||
|
|
@ -193,6 +251,9 @@ pub async fn start(
|
|||
|
||||
// relay reconnection state with exponential backoff
|
||||
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
|
||||
let mut relay_retry_at: Option<tokio::time::Instant> = if relay_multiaddr.is_some() {
|
||||
// schedule initial retry in case the first dial failed synchronously
|
||||
|
|
@ -304,6 +365,18 @@ pub async fn start(
|
|||
});
|
||||
}
|
||||
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
|
||||
let entry = DirectoryEntry {
|
||||
peer_id: profile.peer_id.clone(),
|
||||
|
|
@ -326,6 +399,12 @@ pub async fn start(
|
|||
});
|
||||
}
|
||||
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
|
||||
let _ = storage.remove_directory_entry(&revocation.peer_id);
|
||||
|
||||
|
|
@ -333,6 +412,133 @@ pub async fn start(
|
|||
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);
|
||||
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
|
||||
// rendezvous registrations that were queued before the relay was ready
|
||||
|
|
@ -487,6 +695,13 @@ pub async fn start(
|
|||
if Some(failed_peer) == relay_peer {
|
||||
log::warn!("failed to connect to relay: {}", error);
|
||||
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(
|
||||
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 {
|
||||
// reset backoff on successful connection
|
||||
relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
|
||||
// cancel any pending retry
|
||||
// cancel any pending retry and deferred warning
|
||||
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 {
|
||||
let relay_circuit_addr = addr.clone()
|
||||
|
|
@ -543,6 +761,33 @@ pub async fn start(
|
|||
libp2p::swarm::SwarmEvent::ConnectionClosed { peer_id, num_established, .. } => {
|
||||
if num_established == 0 {
|
||||
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 {
|
||||
peer_id: peer_id.to_string(),
|
||||
});
|
||||
|
|
@ -556,6 +801,13 @@ pub async fn start(
|
|||
if Some(peer_id) == relay_peer {
|
||||
relay_reservation_active = false;
|
||||
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(
|
||||
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() => {
|
||||
match cmd {
|
||||
Some(NodeCommand::Shutdown) | None => break,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ pub fn build_swarm(
|
|||
noise::Config::new,
|
||||
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
|
||||
.with_relay_client(noise::Config::new, yamux::Config::default)?
|
||||
.with_behaviour(|key, relay_client| {
|
||||
|
|
@ -71,10 +73,11 @@ pub fn build_swarm(
|
|||
kademlia,
|
||||
mdns,
|
||||
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();
|
||||
|
||||
Ok(swarm)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ pub struct CommunityMeta {
|
|||
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)]
|
||||
pub struct ChannelMeta {
|
||||
pub id: String,
|
||||
|
|
@ -16,6 +25,9 @@ pub struct ChannelMeta {
|
|||
pub name: String,
|
||||
pub topic: String,
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub struct DuskIdentity {
|
|||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub created_at: u64,
|
||||
pub verification_proof: Option<VerificationProof>,
|
||||
}
|
||||
|
||||
impl DuskIdentity {
|
||||
|
|
@ -29,6 +30,7 @@ impl DuskIdentity {
|
|||
display_name: display_name.to_string(),
|
||||
bio: bio.to_string(),
|
||||
created_at,
|
||||
verification_proof: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +46,7 @@ impl DuskIdentity {
|
|||
let peer_id = PeerId::from(keypair.public());
|
||||
|
||||
let profile = storage.load_profile().unwrap_or_default();
|
||||
let verification_proof = storage.load_verification_proof().ok().flatten();
|
||||
|
||||
Ok(Self {
|
||||
keypair,
|
||||
|
|
@ -51,6 +54,7 @@ impl DuskIdentity {
|
|||
display_name: profile.display_name,
|
||||
bio: profile.bio,
|
||||
created_at: profile.created_at,
|
||||
verification_proof,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +90,7 @@ impl DuskIdentity {
|
|||
public_key: hex::encode(public_key_bytes),
|
||||
bio: self.bio.clone(),
|
||||
created_at: self.created_at,
|
||||
verification_proof: self.verification_proof.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,6 +102,17 @@ pub struct PublicIdentity {
|
|||
pub public_key: String,
|
||||
pub bio: String,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::identity::VerificationProof;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
|
|
@ -34,6 +36,8 @@ pub enum PeerStatus {
|
|||
}
|
||||
|
||||
// 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)]
|
||||
pub struct ProfileAnnouncement {
|
||||
pub peer_id: String,
|
||||
|
|
@ -41,14 +45,64 @@ pub struct ProfileAnnouncement {
|
|||
pub bio: String,
|
||||
pub public_key: String,
|
||||
pub timestamp: u64,
|
||||
pub verification_proof: Option<VerificationProof>,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
// 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)]
|
||||
pub struct ProfileRevocation {
|
||||
pub peer_id: String,
|
||||
pub public_key: String,
|
||||
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
|
||||
|
|
@ -58,8 +112,49 @@ pub enum GossipMessage {
|
|||
Typing(TypingIndicator),
|
||||
Presence(PresenceUpdate),
|
||||
MetaUpdate(super::community::CommunityMeta),
|
||||
DeleteMessage { message_id: String },
|
||||
MemberKicked { peer_id: String },
|
||||
DeleteMessage {
|
||||
message_id: String,
|
||||
},
|
||||
MemberKicked {
|
||||
peer_id: String,
|
||||
},
|
||||
ProfileAnnounce(ProfileAnnouncement),
|
||||
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>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ use std::io;
|
|||
use std::path::PathBuf;
|
||||
|
||||
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
|
||||
#[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("communities"))?;
|
||||
fs::create_dir_all(base_dir.join("directory"))?;
|
||||
fs::create_dir_all(base_dir.join("dms"))?;
|
||||
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
|
|
@ -110,6 +112,25 @@ impl DiskStorage {
|
|||
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 --
|
||||
|
||||
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
|
||||
pub fn wipe_all_data(&self) -> Result<(), io::Error> {
|
||||
let identity_dir = self.base_dir.join("identity");
|
||||
|
|
@ -243,10 +388,16 @@ impl DiskStorage {
|
|||
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
|
||||
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("directory"))?;
|
||||
fs::create_dir_all(self.base_dir.join("dms"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
425
src/App.tsx
|
|
@ -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 OverlayMenu from "./components/navigation/OverlayMenu";
|
||||
import MobileNav from "./components/navigation/MobileNav";
|
||||
|
|
@ -6,7 +15,10 @@ import Modal from "./components/common/Modal";
|
|||
import Button from "./components/common/Button";
|
||||
import SettingsModal from "./components/settings/SettingsModal";
|
||||
import SignUpScreen from "./components/auth/SignUpScreen";
|
||||
import SplashScreen from "./components/auth/SplashScreen";
|
||||
import UserDirectoryModal from "./components/directory/UserDirectoryModal";
|
||||
import ProfileCard from "./components/common/ProfileCard";
|
||||
import ProfileModal from "./components/common/ProfileModal";
|
||||
|
||||
import {
|
||||
overlayMenuOpen,
|
||||
|
|
@ -28,6 +40,9 @@ import {
|
|||
setChannels,
|
||||
setActiveChannel,
|
||||
activeChannelId,
|
||||
setCategories,
|
||||
addCategory,
|
||||
categories,
|
||||
} from "./stores/channels";
|
||||
import {
|
||||
addMessage,
|
||||
|
|
@ -46,6 +61,8 @@ import {
|
|||
setPeerCount,
|
||||
setNodeStatus,
|
||||
setIsConnected,
|
||||
setRelayConnected,
|
||||
relayConnected,
|
||||
} from "./stores/connection";
|
||||
import {
|
||||
setDMConversations,
|
||||
|
|
@ -53,6 +70,12 @@ import {
|
|||
addDMMessage,
|
||||
setActiveDM,
|
||||
updateDMLastMessage,
|
||||
handleIncomingDM,
|
||||
addDMTypingPeer,
|
||||
clearDMTypingPeers,
|
||||
clearDMMessages,
|
||||
setDMMessages,
|
||||
updateDMPeerDisplayName,
|
||||
} from "./stores/dms";
|
||||
import {
|
||||
setKnownPeers,
|
||||
|
|
@ -61,9 +84,21 @@ import {
|
|||
removePeer,
|
||||
clearDirectory,
|
||||
} from "./stores/directory";
|
||||
import {
|
||||
handleVoiceParticipantJoined,
|
||||
handleVoiceParticipantLeft,
|
||||
handleVoiceMediaStateChanged,
|
||||
handleVoiceSdpReceived,
|
||||
handleVoiceIceCandidateReceived,
|
||||
} from "./stores/voice";
|
||||
|
||||
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";
|
||||
|
||||
const App: Component = () => {
|
||||
|
|
@ -73,11 +108,101 @@ const App: Component = () => {
|
|||
const [tauriAvailable, setTauriAvailable] = createSignal(false);
|
||||
const [needsSignUp, setNeedsSignUp] = createSignal(false);
|
||||
const [appReady, setAppReady] = createSignal(false);
|
||||
const [showSplash, setShowSplash] = createSignal(true);
|
||||
const [newCommunityName, setNewCommunityName] = createSignal("");
|
||||
const [newCommunityDesc, setNewCommunityDesc] = createSignal("");
|
||||
const [joinInviteCode, setJoinInviteCode] = createSignal("");
|
||||
const [newChannelName, setNewChannelName] = 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 () => {
|
||||
cleanupResize = initResponsive();
|
||||
|
|
@ -142,6 +267,14 @@ const App: Component = () => {
|
|||
// 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();
|
||||
setCommunities(communities);
|
||||
|
||||
|
|
@ -157,19 +290,10 @@ const App: Component = () => {
|
|||
// from the backend will set the accurate state once peers are found.
|
||||
setNodeStatus("running");
|
||||
|
||||
// the createEffect on activeCommunityId handles loading channels,
|
||||
// messages, and members reactively when this is set
|
||||
if (communities.length > 0) {
|
||||
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) {
|
||||
console.error("initialization error:", e);
|
||||
|
|
@ -219,11 +343,43 @@ const App: Component = () => {
|
|||
event.payload.display_name,
|
||||
event.payload.bio,
|
||||
);
|
||||
// keep dm conversation names in sync
|
||||
updateDMPeerDisplayName(
|
||||
event.payload.peer_id,
|
||||
event.payload.display_name,
|
||||
);
|
||||
break;
|
||||
case "profile_revoked":
|
||||
// peer revoked their identity, remove them from our local directory
|
||||
removePeer(event.payload.peer_id);
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
function handleSendDM(content: string) {
|
||||
async function handleSendDM(content: string) {
|
||||
const peerId = activeDMPeerId();
|
||||
if (!peerId) return;
|
||||
|
||||
const id = identity();
|
||||
const msg = {
|
||||
id: `dm_${Date.now()}`,
|
||||
channel_id: `dm_${peerId}`,
|
||||
author_id: id?.peer_id ?? "local",
|
||||
author_name: id?.display_name ?? "you",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
edited: false,
|
||||
};
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
const msg = await tauri.sendDM(peerId, content);
|
||||
addDMMessage(msg);
|
||||
updateDMLastMessage(peerId, content, msg.timestamp);
|
||||
} catch (e) {
|
||||
console.error("failed to send dm:", e);
|
||||
}
|
||||
} 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);
|
||||
updateDMLastMessage(peerId, content, msg.timestamp);
|
||||
function handleDMTyping() {
|
||||
const peerId = activeDMPeerId();
|
||||
if (!peerId || !tauriAvailable()) return;
|
||||
tauri.sendDMTyping(peerId).catch(() => {});
|
||||
}
|
||||
|
||||
function handleOverlayNavigate(action: string) {
|
||||
|
|
@ -318,17 +489,8 @@ const App: Component = () => {
|
|||
try {
|
||||
const community = await tauri.createCommunity(name, desc);
|
||||
addCommunity(community);
|
||||
// the createEffect on activeCommunityId handles loading channels, messages, members
|
||||
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) {
|
||||
console.error("failed to create community:", e);
|
||||
}
|
||||
|
|
@ -350,6 +512,8 @@ const App: Component = () => {
|
|||
name: "general",
|
||||
topic: "general discussion",
|
||||
kind: "Text",
|
||||
position: 0,
|
||||
category_id: null,
|
||||
},
|
||||
]);
|
||||
setActiveChannel(chId);
|
||||
|
|
@ -379,17 +543,8 @@ const App: Component = () => {
|
|||
try {
|
||||
const community = await tauri.joinCommunity(inviteCode);
|
||||
addCommunity(community);
|
||||
// the createEffect on activeCommunityId handles loading channels, messages, members
|
||||
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) {
|
||||
console.error("failed to join community:", e);
|
||||
}
|
||||
|
|
@ -412,6 +567,8 @@ const App: Component = () => {
|
|||
name: "general",
|
||||
topic: "general discussion",
|
||||
kind: "Text",
|
||||
position: 0,
|
||||
category_id: null,
|
||||
},
|
||||
]);
|
||||
setActiveChannel(chId);
|
||||
|
|
@ -435,35 +592,78 @@ const App: Component = () => {
|
|||
async function handleCreateChannel() {
|
||||
const name = newChannelName().trim();
|
||||
const topic = newChannelTopic().trim();
|
||||
const kind = newChannelKind();
|
||||
const categoryId = newChannelCategoryId();
|
||||
const communityId = activeCommunityId();
|
||||
if (!name || !communityId) return;
|
||||
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
const channel = await tauri.createChannel(communityId, name, topic);
|
||||
const channel = await tauri.createChannel(
|
||||
communityId,
|
||||
name,
|
||||
topic,
|
||||
kind.toLowerCase(),
|
||||
categoryId,
|
||||
);
|
||||
setChannels((prev) => [...prev, channel]);
|
||||
setActiveChannel(channel.id);
|
||||
clearMessages();
|
||||
// only auto-select text channels after creation
|
||||
if (channel.kind === "Text") {
|
||||
setActiveChannel(channel.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("failed to create channel:", e);
|
||||
}
|
||||
} else {
|
||||
// demo mode
|
||||
const chId = `ch_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}`;
|
||||
const channel = {
|
||||
const channel: ChannelMeta = {
|
||||
id: chId,
|
||||
community_id: communityId,
|
||||
name,
|
||||
topic: topic || `${name} discussion`,
|
||||
kind: "Text" as const,
|
||||
kind,
|
||||
position: 0,
|
||||
category_id: categoryId,
|
||||
};
|
||||
setChannels((prev) => [...prev, channel]);
|
||||
setActiveChannel(chId);
|
||||
clearMessages();
|
||||
if (kind === "Text") {
|
||||
setActiveChannel(chId);
|
||||
clearMessages();
|
||||
}
|
||||
}
|
||||
|
||||
setNewChannelName("");
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -487,10 +687,18 @@ const App: Component = () => {
|
|||
closeModal();
|
||||
}
|
||||
|
||||
async function handleSignUpComplete(displayName: string, bio: string) {
|
||||
async function handleSignUpComplete(
|
||||
displayName: string,
|
||||
bio: string,
|
||||
challengeData?: ChallengeExport,
|
||||
) {
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
const created = await tauri.createIdentity(displayName, bio);
|
||||
const created = await tauri.createIdentity(
|
||||
displayName,
|
||||
bio,
|
||||
challengeData,
|
||||
);
|
||||
setCurrentIdentity(created);
|
||||
updateSettings({ display_name: displayName });
|
||||
|
||||
|
|
@ -532,13 +740,16 @@ const App: Component = () => {
|
|||
setCommunities([]);
|
||||
setActiveCommunity(null);
|
||||
setChannels([]);
|
||||
setCategories([]);
|
||||
setActiveChannel(null);
|
||||
clearMessages();
|
||||
setMembers([]);
|
||||
setDMConversations([]);
|
||||
setActiveDM(null);
|
||||
clearDMTypingPeers();
|
||||
setPeerCount(0);
|
||||
setIsConnected(false);
|
||||
setRelayConnected(true);
|
||||
setNodeStatus("stopped");
|
||||
localStorage.removeItem("dusk_user_settings");
|
||||
|
||||
|
|
@ -553,6 +764,14 @@ const App: Component = () => {
|
|||
|
||||
return (
|
||||
<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()}>
|
||||
<SignUpScreen onComplete={handleSignUpComplete} />
|
||||
</Show>
|
||||
|
|
@ -563,8 +782,12 @@ const App: Component = () => {
|
|||
onSendMessage={handleSendMessage}
|
||||
onTyping={handleTyping}
|
||||
onSendDM={handleSendDM}
|
||||
onDMTyping={handleDMTyping}
|
||||
/>
|
||||
|
||||
<ProfileCard />
|
||||
<ProfileModal />
|
||||
|
||||
<OverlayMenu
|
||||
isOpen={overlayMenuOpen()}
|
||||
onClose={closeOverlay}
|
||||
|
|
@ -647,6 +870,37 @@ const App: Component = () => {
|
|||
title="create channel"
|
||||
>
|
||||
<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>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
name
|
||||
|
|
@ -671,6 +925,28 @@ const App: Component = () => {
|
|||
onInput={(e) => setNewChannelTopic(e.currentTarget.value)}
|
||||
/>
|
||||
</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
|
||||
variant="primary"
|
||||
fullWidth
|
||||
|
|
@ -682,6 +958,35 @@ const App: Component = () => {
|
|||
</div>
|
||||
</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
|
||||
isOpen={activeModal() === "settings"}
|
||||
onClose={closeModal}
|
||||
|
|
@ -736,6 +1041,8 @@ function loadDemoData() {
|
|||
name: "general",
|
||||
topic: "general discussion about dusk development",
|
||||
kind: "Text",
|
||||
position: 0,
|
||||
category_id: null,
|
||||
},
|
||||
{
|
||||
id: "ch_design_001",
|
||||
|
|
@ -743,6 +1050,8 @@ function loadDemoData() {
|
|||
name: "design",
|
||||
topic: "UI/UX design discussion",
|
||||
kind: "Text",
|
||||
position: 1,
|
||||
category_id: null,
|
||||
},
|
||||
{
|
||||
id: "ch_voice_001",
|
||||
|
|
@ -750,6 +1059,8 @@ function loadDemoData() {
|
|||
name: "voice",
|
||||
topic: "",
|
||||
kind: "Voice",
|
||||
position: 0,
|
||||
category_id: null,
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -885,7 +1196,6 @@ function loadDemoData() {
|
|||
{
|
||||
peer_id: "12D3KooWPeer_alice",
|
||||
display_name: "alice",
|
||||
status: "Online",
|
||||
last_message: "the gossipsub refactor is merged, check it out",
|
||||
last_message_time: now - 600000,
|
||||
unread_count: 2,
|
||||
|
|
@ -893,7 +1203,6 @@ function loadDemoData() {
|
|||
{
|
||||
peer_id: "12D3KooWPeer_bob",
|
||||
display_name: "bob",
|
||||
status: "Idle",
|
||||
last_message: "sure, i'll review the PR tonight",
|
||||
last_message_time: now - 3600000,
|
||||
unread_count: 0,
|
||||
|
|
@ -901,7 +1210,6 @@ function loadDemoData() {
|
|||
{
|
||||
peer_id: "12D3KooWPeer_charlie",
|
||||
display_name: "charlie",
|
||||
status: "Online",
|
||||
last_message: "NAT traversal test results look promising",
|
||||
last_message_time: now - 7200000,
|
||||
unread_count: 1,
|
||||
|
|
@ -909,7 +1217,6 @@ function loadDemoData() {
|
|||
{
|
||||
peer_id: "12D3KooWPeer_diana",
|
||||
display_name: "diana",
|
||||
status: "Offline",
|
||||
last_message: "offline, will catch up tomorrow",
|
||||
last_message_time: now - 86400000,
|
||||
unread_count: 0,
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
@ -1,28 +1,41 @@
|
|||
import { Component, createSignal, Show } from "solid-js";
|
||||
import { Key, User, ArrowRight, Shield } from "lucide-solid";
|
||||
import { Component, createSignal, Show, Switch, Match } from "solid-js";
|
||||
import { ArrowRight } from "lucide-solid";
|
||||
import Button from "../common/Button";
|
||||
import Avatar from "../common/Avatar";
|
||||
import HumanVerification from "./HumanVerification";
|
||||
import type { ChallengeExport } from "../../lib/types";
|
||||
|
||||
interface SignUpScreenProps {
|
||||
onComplete: (displayName: string, bio: string) => void;
|
||||
onComplete: (
|
||||
displayName: string,
|
||||
bio: string,
|
||||
challengeData: ChallengeExport,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const SignUpScreen: Component<SignUpScreenProps> = (props) => {
|
||||
const [displayName, setDisplayName] = 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 [challengeData, setChallengeData] =
|
||||
createSignal<ChallengeExport | null>(null);
|
||||
|
||||
function handleBegin() {
|
||||
setStep("profile");
|
||||
setStep("verification");
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const name = displayName().trim();
|
||||
if (!name) return;
|
||||
|
||||
const challenge = challengeData();
|
||||
if (!challenge) return;
|
||||
|
||||
setIsCreating(true);
|
||||
props.onComplete(name, bio().trim());
|
||||
props.onComplete(name, bio().trim(), challenge);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
|
|
@ -30,9 +43,10 @@ const SignUpScreen: Component<SignUpScreenProps> = (props) => {
|
|||
e.preventDefault();
|
||||
if (step() === "welcome") {
|
||||
handleBegin();
|
||||
} else if (displayName().trim()) {
|
||||
} else if (step() === "profile" && displayName().trim()) {
|
||||
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"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Show
|
||||
when={step() === "profile"}
|
||||
fallback={
|
||||
// welcome screen
|
||||
<Switch>
|
||||
<Match when={step() === "welcome"}>
|
||||
{/* welcome screen */}
|
||||
<div class="max-w-[520px] w-full mx-4 animate-fade-in">
|
||||
<div class="mb-12">
|
||||
<h1 class="text-[48px] leading-[56px] font-bold text-white tracking-[-0.02em] mb-4">
|
||||
dusk
|
||||
welcome to dusk chat
|
||||
</h1>
|
||||
<p class="text-[20px] leading-[28px] text-white/60">
|
||||
peer-to-peer communication. no servers, no surveillance, no
|
||||
compromise.
|
||||
truly private peer-to-peer messaging for the masses.
|
||||
</p>
|
||||
</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}>
|
||||
<span class="flex items-center gap-2">
|
||||
get started
|
||||
|
|
@ -110,97 +75,110 @@ const SignUpScreen: Component<SignUpScreenProps> = (props) => {
|
|||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* 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>
|
||||
</Match>
|
||||
|
||||
{/* live preview */}
|
||||
<div class="flex items-center gap-4 p-4 border-2 border-white/10 mb-8">
|
||||
<Avatar
|
||||
name={displayName() || "?"}
|
||||
size="xl"
|
||||
status="Online"
|
||||
showStatus
|
||||
/>
|
||||
<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>
|
||||
<Match when={step() === "verification"}>
|
||||
<HumanVerification
|
||||
onVerified={(data) => {
|
||||
setChallengeData(data);
|
||||
setStep("profile");
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
display 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="what should people call you?"
|
||||
value={displayName()}
|
||||
onInput={(e) => setDisplayName(e.currentTarget.value)}
|
||||
maxLength={32}
|
||||
autofocus
|
||||
<Match when={step() === "profile"}>
|
||||
{/* 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 */}
|
||||
<div class="flex items-center gap-4 p-4 border-2 border-white/10 mb-8">
|
||||
<Avatar
|
||||
name={displayName() || "?"}
|
||||
size="xl"
|
||||
status="Online"
|
||||
showStatus
|
||||
/>
|
||||
<p class="text-[12px] font-mono text-white/20 mt-1">
|
||||
{displayName().length}/32
|
||||
</p>
|
||||
<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>
|
||||
<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 class="flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
display 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="what should people call you?"
|
||||
value={displayName()}
|
||||
onInput={(e) => setDisplayName(e.currentTarget.value)}
|
||||
maxLength={32}
|
||||
autofocus
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<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
|
||||
{/* dev mode indicator*/}
|
||||
<Show when={"__TAURI_INTERNALS__" in window}>
|
||||
<div class="fixed bottom-6 left-0 right-0 text-center">
|
||||
<p class="text-[11px] font-mono text-white/10 uppercase tracking-[0.1em]">
|
||||
development version
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -6,6 +6,7 @@ import { removeMessage } from "../../stores/messages";
|
|||
import { activeCommunityId } from "../../stores/communities";
|
||||
import { identity } from "../../stores/identity";
|
||||
import Avatar from "../common/Avatar";
|
||||
import { openProfileCard } from "../../stores/ui";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
|
||||
interface MessageProps {
|
||||
|
|
@ -15,7 +16,10 @@ interface MessageProps {
|
|||
}
|
||||
|
||||
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 currentCommunityId = () => activeCommunityId();
|
||||
|
|
@ -48,6 +52,16 @@ const Message: Component<MessageProps> = (props) => {
|
|||
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
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", closeContextMenu);
|
||||
|
|
@ -70,17 +84,25 @@ const Message: Component<MessageProps> = (props) => {
|
|||
</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" />
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<Show when={props.isFirstInGroup}>
|
||||
<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}
|
||||
</span>
|
||||
</button>
|
||||
<span class="text-[12px] font-mono text-white/50">
|
||||
{formatTime(props.message.timestamp)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const MessageList: Component<MessageListProps> = (props) => {
|
|||
|
||||
// auto-scroll when new messages arrive if user is at the bottom
|
||||
createEffect(() => {
|
||||
const _ = props.messages.length;
|
||||
void props.messages.length;
|
||||
if (isAtBottom()) {
|
||||
// defer to allow dom update
|
||||
requestAnimationFrame(() => scrollToBottom(true));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -4,6 +4,7 @@ import UserFooter from "./UserFooter";
|
|||
interface SidebarLayoutProps {
|
||||
header?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
beforeFooter?: JSX.Element;
|
||||
showFooter?: boolean;
|
||||
showFooterSettings?: boolean;
|
||||
onFooterSettingsClick?: () => void;
|
||||
|
|
@ -24,6 +25,9 @@ const SidebarLayout: Component<ParentProps<SidebarLayoutProps>> = (props) => {
|
|||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* voice controls or other content above the user footer */}
|
||||
{props.beforeFooter}
|
||||
|
||||
{/* footer */}
|
||||
{props.showFooter && (
|
||||
<UserFooter
|
||||
|
|
|
|||
|
|
@ -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
|
||||
try {
|
||||
await tauri.openDMConversation(peerId, displayName);
|
||||
} catch {
|
||||
// fallback for demo mode or if backend call fails
|
||||
}
|
||||
addDMConversation({
|
||||
peer_id: peerId,
|
||||
display_name: displayName,
|
||||
status: "Online",
|
||||
last_message: null,
|
||||
last_message_time: null,
|
||||
unread_count: 0,
|
||||
});
|
||||
setActiveDM(peerId);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Component } 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 ChannelList from "./ChannelList";
|
||||
import ChatArea from "./ChatArea";
|
||||
|
|
@ -20,11 +20,13 @@ import { activeCommunityId } from "../../stores/communities";
|
|||
import { activeDMPeerId } from "../../stores/dms";
|
||||
import { activeChannel } from "../../stores/channels";
|
||||
import { sidebarWidth, updateSidebarWidth } from "../../stores/sidebar";
|
||||
import { relayConnected, nodeStatus } from "../../stores/connection";
|
||||
|
||||
interface AppLayoutProps {
|
||||
onSendMessage: (content: string) => void;
|
||||
onTyping: () => void;
|
||||
onSendDM: (content: string) => void;
|
||||
onDMTyping: () => void;
|
||||
}
|
||||
|
||||
const AppLayout: Component<AppLayoutProps> = (props) => {
|
||||
|
|
@ -33,6 +35,9 @@ const AppLayout: Component<AppLayoutProps> = (props) => {
|
|||
const channel = () => activeChannel();
|
||||
const showSidebar = () => sidebarVisible() && !isMobile() && !isHome();
|
||||
const showChannelHeader = () => !isHome() && channel();
|
||||
// only warn about relay when the node is actually running
|
||||
const showRelayWarning = () =>
|
||||
!relayConnected() && nodeStatus() === "running";
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-screen overflow-hidden bg-black">
|
||||
|
|
@ -42,97 +47,112 @@ const AppLayout: Component<AppLayoutProps> = (props) => {
|
|||
</Show>
|
||||
|
||||
{/* main content area */}
|
||||
<div class="flex flex-1 overflow-hidden min-w-0">
|
||||
<Show
|
||||
when={isHome()}
|
||||
fallback={
|
||||
<>
|
||||
{/* community view: channel list + chat */}
|
||||
<Show when={channelListVisible()}>
|
||||
<ResizablePanel
|
||||
width={sidebarWidth()}
|
||||
minWidth={300}
|
||||
maxWidth={600}
|
||||
side="left"
|
||||
onResize={updateSidebarWidth}
|
||||
>
|
||||
<ChannelList />
|
||||
</ResizablePanel>
|
||||
</Show>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-0">
|
||||
<Show when={showRelayWarning()}>
|
||||
<div class="shrink-0 flex items-center gap-2 px-4 py-2 bg-orange/10 border-b border-orange/20">
|
||||
<WifiOff size={14} class="shrink-0 text-orange" />
|
||||
<span class="text-[13px] font-mono text-orange">
|
||||
relay unreachable -- WAN connectivity limited, retrying in
|
||||
background
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* chat + header container */}
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
{/* channel header */}
|
||||
<Show when={showChannelHeader()}>
|
||||
<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">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Hash size={20} class="shrink-0 text-white/40" />
|
||||
<span class="text-[16px] font-bold text-white truncate">
|
||||
{channel()!.name}
|
||||
</span>
|
||||
<Show when={channel()!.topic}>
|
||||
<div class="w-px h-5 bg-white/20 mx-2 shrink-0" />
|
||||
<span class="text-[14px] text-white/40 truncate">
|
||||
{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>
|
||||
<div class="flex flex-1 overflow-hidden min-w-0">
|
||||
<Show
|
||||
when={isHome()}
|
||||
fallback={
|
||||
<>
|
||||
{/* community view: channel list + chat */}
|
||||
<Show when={channelListVisible()}>
|
||||
<ResizablePanel
|
||||
width={sidebarWidth()}
|
||||
minWidth={300}
|
||||
maxWidth={600}
|
||||
side="left"
|
||||
onResize={updateSidebarWidth}
|
||||
>
|
||||
<ChannelList />
|
||||
</ResizablePanel>
|
||||
</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>
|
||||
{/* chat + header container */}
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
{/* channel header */}
|
||||
<Show when={showChannelHeader()}>
|
||||
<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">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Hash size={20} class="shrink-0 text-white/40" />
|
||||
<span class="text-[16px] font-bold text-white truncate">
|
||||
{channel()!.name}
|
||||
</span>
|
||||
<Show when={channel()!.topic}>
|
||||
<div class="w-px h-5 bg-white/20 mx-2 shrink-0" />
|
||||
<span class="text-[14px] text-white/40 truncate">
|
||||
{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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* home view: dm sidebar + friends list or dm chat */}
|
||||
<ResizablePanel
|
||||
width={sidebarWidth()}
|
||||
minWidth={300}
|
||||
maxWidth={600}
|
||||
side="left"
|
||||
onResize={updateSidebarWidth}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DMSidebar />
|
||||
</ResizablePanel>
|
||||
<Show when={activeDMPeerId()} fallback={<HomeView />}>
|
||||
<DMChatArea onSendDM={props.onSendDM} />
|
||||
{/* home view: dm sidebar + friends list or dm chat */}
|
||||
<ResizablePanel
|
||||
width={sidebarWidth()}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,334 @@
|
|||
import type { Component } 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 {
|
||||
channels,
|
||||
categories,
|
||||
activeChannelId,
|
||||
setActiveChannel,
|
||||
setChannels,
|
||||
reorderChannels,
|
||||
} from "../../stores/channels";
|
||||
import { activeCommunity } from "../../stores/communities";
|
||||
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 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 [textCollapsed, setTextCollapsed] = createSignal(false);
|
||||
const [voiceCollapsed, setVoiceCollapsed] = createSignal(false);
|
||||
// track collapsed state per section via a map keyed by section id
|
||||
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 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 = (
|
||||
<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">
|
||||
|
|
@ -42,96 +353,162 @@ const ChannelList: Component = () => {
|
|||
);
|
||||
|
||||
const body = (
|
||||
<div class="py-3">
|
||||
{/* text channels */}
|
||||
<Show when={textChannels().length > 0}>
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
class="transition-transform duration-300"
|
||||
style={{
|
||||
transform: textCollapsed() ? "rotate(-90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<div class="py-3">
|
||||
{/* uncategorized text channels */}
|
||||
<Show when={uncategorizedText().length > 0}>
|
||||
<SectionHeader
|
||||
sectionId="uncategorized-text"
|
||||
label="text channels"
|
||||
showAdd
|
||||
/>
|
||||
text channels
|
||||
</button>
|
||||
<Show when={!textCollapsed()}>
|
||||
<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)",
|
||||
}}
|
||||
<ChannelGroup
|
||||
sectionId="uncategorized-text"
|
||||
channelList={uncategorizedText()}
|
||||
/>
|
||||
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>
|
||||
|
||||
{/* add channel button */}
|
||||
<Show when={community()}>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => openModal("create-channel")}
|
||||
{/* uncategorized voice channels */}
|
||||
<Show when={uncategorizedVoice().length > 0}>
|
||||
<div class="mt-2">
|
||||
<SectionHeader
|
||||
sectionId="uncategorized-voice"
|
||||
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} />
|
||||
<span>add channel</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="px-2 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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 (
|
||||
<SidebarLayout
|
||||
header={header}
|
||||
beforeFooter={voicePanel()}
|
||||
showFooter
|
||||
showFooterSettings
|
||||
onFooterSettingsClick={() => openModal("settings")}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ interface ChatAreaProps {
|
|||
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 channel = () => activeChannel();
|
||||
|
||||
return (
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-black">
|
||||
{/* message area */}
|
||||
<Show
|
||||
when={channel()}
|
||||
fallback={
|
||||
|
|
|
|||
|
|
@ -1,18 +1,57 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
import { Show, createMemo } from "solid-js";
|
||||
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 MessageInput from "../chat/MessageInput";
|
||||
import TypingIndicator from "../chat/TypingIndicator";
|
||||
import Avatar from "../common/Avatar";
|
||||
import type { ChatMessage } from "../../lib/types";
|
||||
|
||||
interface DMChatAreaProps {
|
||||
onSendDM: (content: string) => void;
|
||||
onTyping: () => void;
|
||||
}
|
||||
|
||||
const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
||||
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 (
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-black">
|
||||
{/* dm header */}
|
||||
|
|
@ -26,14 +65,10 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
|||
</span>
|
||||
<span
|
||||
class={`text-[12px] font-mono ml-1 ${
|
||||
dm()!.status === "Online"
|
||||
? "text-success"
|
||||
: dm()!.status === "Idle"
|
||||
? "text-warning"
|
||||
: "text-white/30"
|
||||
peerStatus() === "online" ? "text-success" : "text-white/30"
|
||||
}`}
|
||||
>
|
||||
{dm()!.status.toLowerCase()}
|
||||
{peerStatus()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -42,7 +77,7 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
|||
|
||||
{/* conversation history */}
|
||||
<Show
|
||||
when={dmMessages().length > 0}
|
||||
when={adaptedMessages().length > 0}
|
||||
fallback={
|
||||
<div class="flex-1 flex flex-col items-center justify-center">
|
||||
<Show when={dm()}>
|
||||
|
|
@ -58,7 +93,12 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<MessageList messages={dmMessages()} />
|
||||
<MessageList messages={adaptedMessages()} />
|
||||
</Show>
|
||||
|
||||
{/* typing indicator */}
|
||||
<Show when={typingNames().length > 0}>
|
||||
<TypingIndicator typingUsers={typingNames()} />
|
||||
</Show>
|
||||
|
||||
{/* message input */}
|
||||
|
|
@ -66,7 +106,7 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
|||
<MessageInput
|
||||
channelName={dm()!.display_name}
|
||||
onSend={props.onSendDM}
|
||||
onTyping={() => {}}
|
||||
onTyping={props.onTyping}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ import {
|
|||
activeDMPeerId,
|
||||
setActiveDM,
|
||||
clearDMUnread,
|
||||
removeDMConversation,
|
||||
} from "../../stores/dms";
|
||||
import { onlinePeerIds } from "../../stores/members";
|
||||
import { openModal } from "../../stores/ui";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Divider from "../common/Divider";
|
||||
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) {
|
||||
setActiveDM(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 = (
|
||||
|
|
@ -86,6 +106,7 @@ const DMSidebar: Component = () => {
|
|||
type="button"
|
||||
class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
title="new dm"
|
||||
onClick={() => openModal("directory")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
|
@ -118,7 +139,7 @@ const DMSidebar: Component = () => {
|
|||
<Avatar
|
||||
name={dm.display_name}
|
||||
size="sm"
|
||||
status={dm.status}
|
||||
status={peerStatus(dm.peer_id)}
|
||||
showStatus
|
||||
/>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
|
|
@ -126,11 +147,21 @@ const DMSidebar: Component = () => {
|
|||
<span class="text-[14px] font-medium truncate">
|
||||
{dm.display_name}
|
||||
</span>
|
||||
<Show when={dm.unread_count > 0}>
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-orange text-white text-[11px] font-bold rounded-full shrink-0">
|
||||
{dm.unread_count}
|
||||
</span>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Show when={dm.unread_count > 0}>
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-orange text-white text-[11px] font-bold rounded-full">
|
||||
{dm.unread_count}
|
||||
</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>
|
||||
<Show when={dm.last_message}>
|
||||
<p class="text-[12px] text-white/40 truncate mt-0.5">
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@ import type { Component } from "solid-js";
|
|||
import { For, Show, createSignal, createMemo } from "solid-js";
|
||||
import { Users, MessageCircle, Search, UserPlus } from "lucide-solid";
|
||||
import {
|
||||
dmConversations,
|
||||
setActiveDM,
|
||||
clearDMUnread,
|
||||
addDMConversation,
|
||||
} from "../../stores/dms";
|
||||
import { knownPeers, friends } from "../../stores/directory";
|
||||
import { onlinePeerIds } from "../../stores/members";
|
||||
import { identity } from "../../stores/identity";
|
||||
import { peerCount, nodeStatus } from "../../stores/connection";
|
||||
import { openModal } from "../../stores/ui";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Divider from "../common/Divider";
|
||||
|
||||
|
|
@ -21,34 +22,18 @@ const HomeView: Component = () => {
|
|||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
|
||||
// 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 friendList = friends();
|
||||
const dms = dmConversations();
|
||||
const onlineSet = onlinePeerIds();
|
||||
|
||||
// merge friends from directory with dm conversations
|
||||
const merged = friendList.map((f) => ({
|
||||
return friendList.map((f) => ({
|
||||
peer_id: f.peer_id,
|
||||
display_name: f.display_name,
|
||||
bio: f.bio,
|
||||
// check dm conversations for status, default to offline
|
||||
status: (dms.find((d) => d.peer_id === f.peer_id)?.status ??
|
||||
"Offline") as "Online" | "Idle" | "Offline",
|
||||
status: (onlineSet.has(f.peer_id) ? "Online" : "Offline") as
|
||||
| "Online"
|
||||
| "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)
|
||||
|
|
@ -71,11 +56,14 @@ const HomeView: Component = () => {
|
|||
p.peer_id.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
const onlineSet = onlinePeerIds();
|
||||
return peers.map((p) => ({
|
||||
peer_id: p.peer_id,
|
||||
display_name: p.display_name,
|
||||
bio: p.bio,
|
||||
status: "Online" as const,
|
||||
status: (onlineSet.has(p.peer_id) ? "Online" : "Offline") as
|
||||
| "Online"
|
||||
| "Offline",
|
||||
is_friend: p.is_friend,
|
||||
}));
|
||||
}
|
||||
|
|
@ -84,7 +72,7 @@ const HomeView: Component = () => {
|
|||
|
||||
// filter by tab
|
||||
if (tab === "online") {
|
||||
peers = peers.filter((p) => p.status === "Online" || p.status === "Idle");
|
||||
peers = peers.filter((p) => p.status === "Online");
|
||||
}
|
||||
|
||||
// filter by search
|
||||
|
|
@ -96,22 +84,36 @@ const HomeView: Component = () => {
|
|||
});
|
||||
|
||||
const onlineCount = () =>
|
||||
allPeers().filter((p) => p.status === "Online" || p.status === "Idle")
|
||||
.length;
|
||||
allPeers().filter((p) => p.status === "Online").length;
|
||||
|
||||
function handleOpenDM(peerId: string) {
|
||||
// ensure a dm conversation exists for this peer
|
||||
const peer = allPeers().find((p) => p.peer_id === peerId);
|
||||
if (peer) {
|
||||
addDMConversation({
|
||||
peer_id: peer.peer_id,
|
||||
display_name: peer.display_name,
|
||||
status: peer.status,
|
||||
unread_count: 0,
|
||||
const peer =
|
||||
allPeers().find((p) => p.peer_id === peerId) ??
|
||||
directoryPeers().find((p) => p.peer_id === peerId);
|
||||
if (!peer) return;
|
||||
|
||||
const displayName = peer.display_name;
|
||||
|
||||
// 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 }[] = [
|
||||
|
|
@ -259,11 +261,7 @@ const HomeView: Component = () => {
|
|||
</p>
|
||||
</Show>
|
||||
<p class="text-[13px] font-mono text-white/40 lowercase">
|
||||
{peer.status === "Online"
|
||||
? "online"
|
||||
: peer.status === "Idle"
|
||||
? "idle"
|
||||
: "offline"}
|
||||
{peer.status === "Online" ? "online" : "offline"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,17 @@ import { activeCommunityId } from "../../stores/communities";
|
|||
import { identity } from "../../stores/identity";
|
||||
import Avatar from "../common/Avatar";
|
||||
import SidebarLayout from "../common/SidebarLayout";
|
||||
import { openProfileCard } from "../../stores/ui";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
|
||||
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 memberList = members();
|
||||
|
|
@ -27,7 +34,10 @@ const UserSidebar: Component = () => {
|
|||
const currentUser = () => identity();
|
||||
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();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
|
|
@ -51,7 +61,9 @@ const UserSidebar: Component = () => {
|
|||
if (!user) return;
|
||||
|
||||
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) {
|
||||
console.error("not authorized to kick members");
|
||||
|
|
@ -93,6 +105,14 @@ const UserSidebar: Component = () => {
|
|||
<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"
|
||||
onClick={(e) => {
|
||||
openProfileCard({
|
||||
peerId: member.peer_id,
|
||||
displayName: member.display_name,
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
});
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, member)}
|
||||
>
|
||||
<Avatar
|
||||
|
|
@ -126,9 +146,16 @@ const UserSidebar: Component = () => {
|
|||
<Show when={contextMenu()}>
|
||||
{(menu) => {
|
||||
const user = currentUser();
|
||||
const currentMember = user ? members().find((m) => m.peer_id === 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;
|
||||
const currentMember = user
|
||||
? members().find((m) => m.peer_id === 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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
154
src/lib/tauri.ts
|
|
@ -4,11 +4,17 @@ import type {
|
|||
PublicIdentity,
|
||||
CommunityMeta,
|
||||
ChannelMeta,
|
||||
CategoryMeta,
|
||||
ChatMessage,
|
||||
Member,
|
||||
DuskEvent,
|
||||
UserSettings,
|
||||
DirectoryEntry,
|
||||
ChallengeExport,
|
||||
VoiceParticipant,
|
||||
VoiceMediaState,
|
||||
DirectMessage,
|
||||
DMConversationMeta,
|
||||
} from "./types";
|
||||
|
||||
// -- identity --
|
||||
|
|
@ -24,8 +30,9 @@ export async function loadIdentity(): Promise<PublicIdentity | null> {
|
|||
export async function createIdentity(
|
||||
displayName: string,
|
||||
bio?: string,
|
||||
challengeData?: ChallengeExport,
|
||||
): Promise<PublicIdentity> {
|
||||
return invoke("create_identity", { displayName, bio });
|
||||
return invoke("create_identity", { displayName, bio, challengeData });
|
||||
}
|
||||
|
||||
export async function updateDisplayName(name: string): Promise<void> {
|
||||
|
|
@ -88,14 +95,42 @@ export async function createChannel(
|
|||
communityId: string,
|
||||
name: string,
|
||||
topic: string,
|
||||
kind?: string,
|
||||
categoryId?: string | null,
|
||||
): 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[]> {
|
||||
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 --
|
||||
|
||||
export async function sendMessage(
|
||||
|
|
@ -171,6 +206,12 @@ export async function resetIdentity(): Promise<void> {
|
|||
return invoke("reset_identity");
|
||||
}
|
||||
|
||||
// -- connectivity --
|
||||
|
||||
export async function checkInternetConnectivity(): Promise<boolean> {
|
||||
return invoke("check_internet_connectivity");
|
||||
}
|
||||
|
||||
// -- events --
|
||||
|
||||
export function onDuskEvent(
|
||||
|
|
@ -178,3 +219,112 @@ export function onDuskEvent(
|
|||
): Promise<UnlistenFn> {
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
133
src/lib/types.ts
|
|
@ -1,12 +1,48 @@
|
|||
// shared type definitions mirroring the rust structs
|
||||
// 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 {
|
||||
peer_id: string;
|
||||
display_name: string;
|
||||
public_key: string;
|
||||
bio: string;
|
||||
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";
|
||||
|
|
@ -45,6 +81,16 @@ export interface ChannelMeta {
|
|||
name: string;
|
||||
topic: string;
|
||||
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 {
|
||||
|
|
@ -57,6 +103,25 @@ export interface ChatMessage {
|
|||
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 {
|
||||
peer_id: string;
|
||||
display_name: string;
|
||||
|
|
@ -82,6 +147,21 @@ export interface DirectoryEntry {
|
|||
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
|
||||
export type DuskEvent =
|
||||
| { kind: "message_received"; payload: ChatMessage }
|
||||
|
|
@ -96,4 +176,55 @@ export type DuskEvent =
|
|||
kind: "profile_received";
|
||||
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 } };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
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 [categories, setCategories] = createSignal<CategoryMeta[]>([]);
|
||||
const [activeChannelId, setActiveChannelId] = createSignal<string | null>(null);
|
||||
|
||||
export function setActiveChannel(id: string | null) {
|
||||
|
|
@ -12,4 +15,20 @@ export function activeChannel(): ChannelMeta | undefined {
|
|||
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 };
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const [peerCount, setPeerCount] = createSignal(0);
|
|||
const [nodeStatus, setNodeStatus] = createSignal<
|
||||
"starting" | "running" | "stopped" | "error"
|
||||
>("stopped");
|
||||
const [relayConnected, setRelayConnected] = createSignal(true);
|
||||
|
||||
export {
|
||||
isConnected,
|
||||
|
|
@ -13,4 +14,6 @@ export {
|
|||
setPeerCount,
|
||||
nodeStatus,
|
||||
setNodeStatus,
|
||||
relayConnected,
|
||||
setRelayConnected,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,33 +1,25 @@
|
|||
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
|
||||
export interface DMConversation {
|
||||
peer_id: string;
|
||||
display_name: string;
|
||||
status: "Online" | "Idle" | "Offline";
|
||||
last_message?: string;
|
||||
last_message_time?: number;
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
const [dmConversations, setDMConversations] = createSignal<DMConversation[]>(
|
||||
[],
|
||||
);
|
||||
// dm conversations loaded from disk via tauri backend
|
||||
const [dmConversations, setDMConversations] = createSignal<
|
||||
DMConversationMeta[]
|
||||
>([]);
|
||||
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) {
|
||||
setActiveDMPeerId(peerId);
|
||||
}
|
||||
|
||||
export function activeDMConversation(): DMConversation | undefined {
|
||||
export function activeDMConversation(): DMConversationMeta | undefined {
|
||||
return dmConversations().find((dm) => dm.peer_id === activeDMPeerId());
|
||||
}
|
||||
|
||||
export function addDMConversation(dm: DMConversation) {
|
||||
export function addDMConversation(dm: DMConversationMeta) {
|
||||
setDMConversations((prev) => {
|
||||
// avoid duplicates
|
||||
if (prev.some((existing) => existing.peer_id === dm.peer_id)) return prev;
|
||||
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]);
|
||||
}
|
||||
|
||||
|
|
@ -76,10 +68,78 @@ export function clearDMMessages() {
|
|||
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 {
|
||||
dmConversations,
|
||||
activeDMPeerId,
|
||||
dmMessages,
|
||||
dmTypingPeers,
|
||||
setDMConversations,
|
||||
setDMMessages,
|
||||
};
|
||||
|
|
|
|||