feat: enhance DM search functionality and improve message loading

- Refactor DMSearchPanel to utilize a debounced search mechanism with improved filtering options.
- Implement asynchronous message searching with new searchDMMessages API.
- Introduce VirtualMessageList for optimized rendering of messages in DMChatArea.
- Add unread message count indicator in ServerList for better user awareness.
- Enhance message loading logic to support pagination and lazy loading of older messages.
- Update types and stores to accommodate new search filters and message handling.
This commit is contained in:
cloudwithax 2026-02-15 22:46:59 -05:00
parent a4e7e51f50
commit 11a987e0de
24 changed files with 2510 additions and 504 deletions

74
src-tauri/Cargo.lock generated
View File

@ -43,6 +43,18 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -1287,6 +1299,7 @@ dependencies = [
"libp2p",
"log",
"rand 0.8.5",
"rusqlite",
"serde",
"serde_json",
"sha2",
@ -1473,6 +1486,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@ -2058,6 +2083,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -2075,6 +2109,15 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@ -3318,6 +3361,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
@ -4944,6 +4998,20 @@ dependencies = [
"tokio",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.10.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@ -6559,6 +6627,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"

View File

@ -52,6 +52,7 @@ hex = "0.4"
# data storage
directories = "5"
rusqlite = { version = "0.32", features = ["bundled"] }
# env file support
dotenvy = "0.15"

View File

@ -129,7 +129,13 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re
let namespace = format!("dusk/community/{}", community_id);
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous { namespace })
.send(NodeCommand::RegisterRendezvous {
namespace: namespace.clone(),
})
.await;
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous { namespace })
.await;
}

View File

@ -3,6 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use tauri::State;
use crate::crdt::sync::{DocumentSnapshot, SyncMessage};
use crate::node::gossip;
use crate::node::NodeCommand;
use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, CommunityMeta, Member};
@ -34,14 +35,60 @@ fn check_permission(
// helper to broadcast a crdt change to peers via the sync topic
async fn broadcast_sync(state: &State<'_, AppState>, community_id: &str) {
let doc_bytes = {
let mut engine = state.crdt_engine.lock().await;
engine.get_doc_bytes(community_id)
};
let Some(doc_bytes) = doc_bytes else {
return;
};
let sync_msg = SyncMessage::DocumentOffer(DocumentSnapshot {
community_id: community_id.to_string(),
doc_bytes,
});
let data = match serde_json::to_vec(&sync_msg) {
Ok(data) => data,
Err(_) => return,
};
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(),
topic: gossip::topic_for_sync(),
data,
})
.await;
}
}
// request a full sync from currently connected peers
async fn request_sync(state: &State<'_, AppState>) {
let peer_id = {
let identity = state.identity.lock().await;
let Some(id) = identity.as_ref() else {
return;
};
id.peer_id.to_string()
};
let sync_msg = SyncMessage::RequestSync { peer_id };
let data = match serde_json::to_vec(&sync_msg) {
Ok(data) => data,
Err(_) => return,
};
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: gossip::topic_for_sync(),
data,
})
.await;
}
@ -131,20 +178,16 @@ pub async fn join_community(
let invite = crate::protocol::community::InviteCode::decode(&invite_code)?;
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let peer_id_str = id.peer_id.to_string();
if identity.is_none() {
return Err("no identity loaded".to_string());
}
drop(identity);
// create a placeholder document that will be backfilled via crdt sync
// once we connect to existing community members through the relay
let mut engine = state.crdt_engine.lock().await;
if !engine.has_community(&invite.community_id) {
engine.create_community(
&invite.community_id,
&invite.community_name,
"",
&peer_id_str,
)?;
engine.create_placeholder_community(&invite.community_id, &invite.community_name, "")?;
}
let meta = engine.get_community_meta(&invite.community_id)?;
@ -200,6 +243,9 @@ pub async fn join_community(
.await;
}
// request a snapshot now so joins work even when peers were already connected
request_sync(&state).await;
Ok(meta)
}
@ -208,26 +254,50 @@ pub async fn leave_community(
state: State<'_, AppState>,
community_id: String,
) -> Result<(), String> {
// unsubscribe from all community topics
let local_peer_id = {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
id.peer_id.to_string()
};
// remove local user from the shared member list before leaving
let mut removed_self = false;
let channels = {
let mut engine = state.crdt_engine.lock().await;
let channels = engine.get_channels(&community_id).unwrap_or_default();
if let Ok(members) = engine.get_members(&community_id) {
if members.iter().any(|member| member.peer_id == local_peer_id) {
if engine.remove_member(&community_id, &local_peer_id).is_ok() {
removed_self = true;
}
}
}
channels
};
if removed_self {
broadcast_sync(&state, &community_id).await;
}
// unsubscribe from all community topics and stop advertising this namespace
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let engine = state.crdt_engine.lock().await;
if let Ok(channels) = engine.get_channels(&community_id) {
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe { topic: msg_topic })
.await;
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe {
topic: typing_topic,
})
.await;
}
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe {
topic: typing_topic,
})
.await;
}
let presence_topic = gossip::topic_for_presence(&community_id);
@ -237,8 +307,18 @@ pub async fn leave_community(
topic: presence_topic,
})
.await;
let namespace = format!("dusk/community/{}", community_id);
let _ = handle
.command_tx
.send(NodeCommand::UnregisterRendezvous { namespace })
.await;
}
// remove local cached community state so leave persists across restarts
let mut engine = state.crdt_engine.lock().await;
engine.remove_community(&community_id)?;
Ok(())
}
@ -313,6 +393,8 @@ pub async fn create_channel(
.await;
}
broadcast_sync(&state, &community_id).await;
Ok(channel)
}
@ -353,18 +435,7 @@ pub async fn create_category(
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;
}
broadcast_sync(&state, &community_id).await;
Ok(category)
}

View File

@ -7,6 +7,7 @@ use crate::node::NodeCommand;
use crate::protocol::messages::{
DMConversationMeta, DMTypingIndicator, DirectMessage, GossipMessage,
};
use crate::storage::DmSearchParams;
use crate::AppState;
// send a direct message to a peer
@ -138,6 +139,48 @@ pub async fn get_dm_messages(
.map_err(|e| format!("failed to load dm messages: {}", e))
}
// search dm messages on the backend using sqlite indexes
#[tauri::command]
pub async fn search_dm_messages(
state: State<'_, AppState>,
peer_id: String,
query: Option<String>,
from_filter: Option<String>,
media_filter: Option<String>,
mentions_only: Option<bool>,
date_after: Option<u64>,
date_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);
let from_peer = match from_filter.as_deref() {
Some("me") => Some(local_peer_id),
Some("them") => Some(peer_id.clone()),
_ => None,
};
let params = DmSearchParams {
query,
from_peer,
media_filter,
mentions_only: mentions_only.unwrap_or(false),
date_after,
date_before,
limit: limit.unwrap_or(200),
};
state
.storage
.search_dm_messages(&conversation_id, &params)
.map_err(|e| format!("failed to search dm messages: {}", e))
}
// load all dm conversations for the sidebar
#[tauri::command]
pub async fn get_dm_conversations(

View File

@ -323,7 +323,10 @@ pub async fn set_relay_address(
{
let mut node_handle = state.node_handle.lock().await;
if let Some(handle) = node_handle.take() {
let _ = handle.command_tx.send(crate::node::NodeCommand::Shutdown).await;
let _ = handle
.command_tx
.send(crate::node::NodeCommand::Shutdown)
.await;
let _ = handle.task.await;
}
}
@ -409,10 +412,7 @@ pub async fn reset_identity(state: State<'_, AppState>) -> Result<(), String> {
// write an svg string to a cache directory and return the absolute path
// used for notification icons so the os can display the user's avatar
#[tauri::command]
pub async fn cache_avatar_icon(
cache_key: String,
svg_content: String,
) -> Result<String, String> {
pub async fn cache_avatar_icon(cache_key: String, svg_content: String) -> Result<String, String> {
let cache_dir = std::env::temp_dir().join("dusk-avatars");
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("failed to create avatar cache dir: {}", e))?;

View File

@ -44,8 +44,7 @@ pub async fn join_voice_channel(
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 data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
@ -95,8 +94,7 @@ pub async fn leave_voice_channel(
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 data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
@ -108,9 +106,7 @@ pub async fn leave_voice_channel(
// unsubscribe from the voice topic
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe {
topic: voice_topic,
})
.send(NodeCommand::Unsubscribe { topic: voice_topic })
.await;
}
@ -151,8 +147,7 @@ pub async fn update_voice_media_state(
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 data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
@ -200,8 +195,7 @@ pub async fn send_voice_sdp(
sdp_type,
sdp,
};
let data =
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
@ -241,8 +235,7 @@ pub async fn send_voice_ice_candidate(
sdp_mid,
sdp_mline_index,
};
let data =
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {

View File

@ -50,6 +50,32 @@ pub fn init_community_doc(
Ok(())
}
// initialize a placeholder community document used while waiting for remote sync
// this avoids granting local owner role or creating channels that may conflict
pub fn init_placeholder_community_doc(
doc: &mut AutoCommit,
name: &str,
description: &str,
) -> Result<(), automerge::AutomergeError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let meta = doc.put_object(ROOT, "meta", ObjType::Map)?;
doc.put(&meta, "name", name)?;
doc.put(&meta, "description", description)?;
doc.put(&meta, "created_by", "")?;
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)?;
Ok(())
}
// add a new channel to the community document
pub fn add_channel(
doc: &mut AutoCommit,

View File

@ -64,6 +64,22 @@ impl CrdtEngine {
Ok(())
}
// create a minimal community document while waiting for remote sync
pub fn create_placeholder_community(
&mut self,
community_id: &str,
name: &str,
description: &str,
) -> Result<(), String> {
let mut doc = AutoCommit::new();
document::init_placeholder_community_doc(&mut doc, name, description)
.map_err(|e| format!("failed to init placeholder community doc: {}", e))?;
self.documents.insert(community_id.to_string(), doc);
self.persist(community_id)?;
Ok(())
}
// add a channel to an existing community
pub fn create_channel(
&mut self,
@ -189,6 +205,18 @@ impl CrdtEngine {
self.documents.contains_key(community_id)
}
// fully remove a community from memory and disk
pub fn remove_community(&mut self, community_id: &str) -> Result<(), String> {
self.documents.remove(community_id);
self.storage
.delete_document(community_id)
.map_err(|e| format!("failed to delete community document: {}", e))?;
self.storage
.delete_community_meta(community_id)
.map_err(|e| format!("failed to delete community meta: {}", e))?;
Ok(())
}
// save a document to disk
pub fn persist(&mut self, community_id: &str) -> Result<(), String> {
let doc = self
@ -350,11 +378,7 @@ impl CrdtEngine {
}
// remove a category and ungroup its channels
pub fn delete_category(
&mut self,
community_id: &str,
category_id: &str,
) -> Result<(), String> {
pub fn delete_category(&mut self, community_id: &str, category_id: &str) -> Result<(), String> {
let doc = self
.documents
.get_mut(community_id)

View File

@ -19,6 +19,7 @@ use axum::Router;
use serde::Deserialize;
use tokio::sync::Mutex;
use crate::crdt::sync::{DocumentSnapshot, SyncMessage};
use crate::crdt::CrdtEngine;
use crate::node::gossip;
use crate::node::NodeCommand;
@ -134,6 +135,67 @@ fn now_ms() -> u64 {
.as_millis() as u64
}
// publish the latest document snapshot for a community to connected peers
async fn broadcast_sync(state: &DevState, community_id: &str) {
let doc_bytes = {
let mut engine = state.crdt_engine.lock().await;
engine.get_doc_bytes(community_id)
};
let Some(doc_bytes) = doc_bytes else {
return;
};
let message = SyncMessage::DocumentOffer(DocumentSnapshot {
community_id: community_id.to_string(),
doc_bytes,
});
let data = match serde_json::to_vec(&message) {
Ok(data) => data,
Err(_) => return,
};
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: gossip::topic_for_sync(),
data,
})
.await;
}
}
// request snapshots from connected peers
async fn request_sync(state: &DevState) {
let peer_id = {
let identity = state.identity.lock().await;
let Some(id) = identity.as_ref() else {
return;
};
id.peer_id.to_string()
};
let message = SyncMessage::RequestSync { peer_id };
let data = match serde_json::to_vec(&message) {
Ok(data) => data,
Err(_) => return,
};
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: gossip::topic_for_sync(),
data,
})
.await;
}
}
// find the community that owns a given channel
fn find_community_for_channel(
engine: &crate::crdt::CrdtEngine,
@ -405,21 +467,18 @@ async fn join_community(
.map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
let identity = state.identity.lock().await;
let id = identity
.as_ref()
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "no identity loaded".into()))?;
let peer_id_str = id.peer_id.to_string();
if identity.is_none() {
return Err(ApiError(
StatusCode::UNAUTHORIZED,
"no identity loaded".into(),
));
}
drop(identity);
let mut engine = state.crdt_engine.lock().await;
if !engine.has_community(&invite.community_id) {
engine
.create_community(
&invite.community_id,
&invite.community_name,
"",
&peer_id_str,
)
.create_placeholder_community(&invite.community_id, &invite.community_name, "")
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))?;
}
@ -473,6 +532,8 @@ async fn join_community(
.await;
}
request_sync(&state).await;
Ok(Json(meta))
}
@ -480,25 +541,50 @@ async fn leave_community(
State(state): State<DevState>,
Path(community_id): Path<String>,
) -> ApiResult<serde_json::Value> {
let local_peer_id = {
let identity = state.identity.lock().await;
let id = identity
.as_ref()
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "no identity loaded".into()))?;
id.peer_id.to_string()
};
let mut removed_self = false;
let channels = {
let mut engine = state.crdt_engine.lock().await;
let channels = engine.get_channels(&community_id).unwrap_or_default();
if let Ok(members) = engine.get_members(&community_id) {
if members.iter().any(|member| member.peer_id == local_peer_id) {
if engine.remove_member(&community_id, &local_peer_id).is_ok() {
removed_self = true;
}
}
}
channels
};
if removed_self {
broadcast_sync(&state, &community_id).await;
}
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let engine = state.crdt_engine.lock().await;
if let Ok(channels) = engine.get_channels(&community_id) {
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe { topic: msg_topic })
.await;
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe {
topic: typing_topic,
})
.await;
}
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe {
topic: typing_topic,
})
.await;
}
let presence_topic = gossip::topic_for_presence(&community_id);
@ -508,8 +594,19 @@ async fn leave_community(
topic: presence_topic,
})
.await;
let namespace = format!("dusk/community/{}", community_id);
let _ = handle
.command_tx
.send(NodeCommand::UnregisterRendezvous { namespace })
.await;
}
let mut engine = state.crdt_engine.lock().await;
engine
.remove_community(&community_id)
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(serde_json::json!({ "ok": true })))
}
@ -631,6 +728,8 @@ async fn create_channel(
.await;
}
broadcast_sync(&state, &community_id).await;
Ok(Json(channel))
}
@ -1035,7 +1134,13 @@ async fn start_node(State(state): State<DevState>) -> ApiResult<serde_json::Valu
let namespace = format!("dusk/community/{}", community_id);
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous { namespace })
.send(NodeCommand::RegisterRendezvous {
namespace: namespace.clone(),
})
.await;
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous { namespace })
.await;
}

View File

@ -147,6 +147,7 @@ pub fn run() {
commands::voice::get_voice_participants,
commands::dm::send_dm,
commands::dm::get_dm_messages,
commands::dm::search_dm_messages,
commands::dm::get_dm_conversations,
commands::dm::mark_dm_read,
commands::dm::delete_dm_conversation,

View File

@ -94,6 +94,10 @@ pub enum NodeCommand {
DiscoverRendezvous {
namespace: String,
},
// unregister from a rendezvous namespace we no longer participate in
UnregisterRendezvous {
namespace: String,
},
// send a gif search request to the relay peer via request-response
GifSearch {
request: crate::protocol::gif::GifRequest,
@ -379,14 +383,50 @@ pub async fn start(
continue;
}
match engine.merge_remote_doc(&snapshot.community_id, &snapshot.doc_bytes) {
let community_id = snapshot.community_id.clone();
let merge_result = engine.merge_remote_doc(&community_id, &snapshot.doc_bytes);
let channels_after_merge = if merge_result.is_ok() {
engine.get_channels(&community_id).unwrap_or_default()
} else {
Vec::new()
};
drop(engine);
match merge_result {
Ok(()) => {
// keep topic subscriptions aligned with merged channels
let presence_topic = libp2p::gossipsub::IdentTopic::new(
gossip::topic_for_presence(&community_id),
);
let _ = swarm_instance
.behaviour_mut()
.gossipsub
.subscribe(&presence_topic);
for channel in &channels_after_merge {
let messages_topic = libp2p::gossipsub::IdentTopic::new(
gossip::topic_for_messages(&community_id, &channel.id),
);
let _ = swarm_instance
.behaviour_mut()
.gossipsub
.subscribe(&messages_topic);
let typing_topic = libp2p::gossipsub::IdentTopic::new(
gossip::topic_for_typing(&community_id, &channel.id),
);
let _ = swarm_instance
.behaviour_mut()
.gossipsub
.subscribe(&typing_topic);
}
let _ = app_handle.emit("dusk-event", DuskEvent::SyncComplete {
community_id: snapshot.community_id,
community_id,
});
}
Err(e) => {
log::warn!("failed to merge remote doc for {}: {}", snapshot.community_id, e);
log::warn!("failed to merge remote doc for {}: {}", community_id, e);
}
}
}
@ -1161,6 +1201,29 @@ pub async fn start(
pending_discoveries.push(namespace);
}
}
Some(NodeCommand::UnregisterRendezvous { namespace }) => {
pending_registrations.retain(|ns| ns != &namespace);
pending_discoveries.retain(|ns| ns != &namespace);
if pending_registrations.is_empty() && pending_discoveries.is_empty() {
pending_queued_at = None;
}
registered_namespaces.remove(&namespace);
if relay_reservation_active {
if let Some(rp) = relay_peer {
match libp2p::rendezvous::Namespace::new(namespace.clone()) {
Ok(ns) => {
swarm_instance.behaviour_mut().rendezvous.unregister(ns, rp);
}
Err(e) => log::warn!(
"invalid rendezvous namespace '{}': {:?}",
namespace,
e
),
}
}
}
}
Some(NodeCommand::GifSearch { request, reply }) => {
if let Some(rp) = relay_peer {
let request_id = swarm_instance

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
mod disk;
pub use disk::DiskStorage;
pub use disk::DmSearchParams;
pub use disk::UserSettings;

View File

@ -60,13 +60,17 @@ fn score_timing_variance(segments: &[SegmentData]) -> f64 {
return 0.0;
}
let intervals: Vec<f64> = segments.iter().map(|s| s.click_time - s.start_time).collect();
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 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
@ -286,8 +290,8 @@ pub fn generate_proof(
}
// 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 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());
@ -329,7 +333,10 @@ fn announcement_sign_payload(
.into_bytes()
}
pub fn sign_announcement(keypair: &identity::Keypair, announcement: &ProfileAnnouncement) -> String {
pub fn sign_announcement(
keypair: &identity::Keypair,
announcement: &ProfileAnnouncement,
) -> String {
let metrics_hash = announcement
.verification_proof
.as_ref()

View File

@ -141,6 +141,73 @@ const App: Component = () => {
const [inviteCode, setInviteCode] = createSignal("");
const [inviteLoading, setInviteLoading] = createSignal(false);
const [inviteCopied, setInviteCopied] = createSignal(false);
let communityLoadSeq = 0;
async function hydrateCommunityState(
communityId: string,
preserveActiveChannel: boolean,
reloadMessagesOnStableChannel = false,
) {
if (!tauriAvailable()) return;
const loadSeq = ++communityLoadSeq;
try {
const [chs, cats, mems] = await Promise.all([
tauri.getChannels(communityId),
tauri.getCategories(communityId),
tauri.getMembers(communityId),
]);
if (loadSeq !== communityLoadSeq || activeCommunityId() !== communityId) {
return;
}
setChannels(chs);
setCategories(cats);
setMembers(mems);
const currentChannelId = activeChannelId();
const activeStillExists =
!!currentChannelId && chs.some((channel) => channel.id === currentChannelId);
let nextChannelId: string | null = null;
if (preserveActiveChannel && activeStillExists) {
nextChannelId = currentChannelId;
} else if (chs.length > 0) {
const last = getLastChannel(communityId);
const restored = !!last && chs.some((channel) => channel.id === last);
nextChannelId = restored ? last : chs[0].id;
}
if (nextChannelId !== currentChannelId) {
setActiveChannel(nextChannelId);
if (!nextChannelId) {
clearMessages();
}
return;
}
if (!nextChannelId) {
clearMessages();
return;
}
if (reloadMessagesOnStableChannel) {
const msgs = await tauri.getMessages(nextChannelId);
if (
loadSeq !== communityLoadSeq ||
activeCommunityId() !== communityId ||
activeChannelId() !== nextChannelId
) {
return;
}
setMessages(msgs);
}
} catch (e) {
console.error("failed to hydrate community state:", e);
}
}
// react to community switches by loading channels, members, and selecting first channel
createEffect(
@ -156,28 +223,7 @@ const App: Component = () => {
}
if (tauriAvailable()) {
try {
const [chs, cats] = await Promise.all([
tauri.getChannels(communityId),
tauri.getCategories(communityId),
]);
setChannels(chs);
setCategories(cats);
if (chs.length > 0) {
const last = getLastChannel(communityId);
const restored = last && chs.some((c) => c.id === last);
setActiveChannel(restored ? last : 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);
}
await hydrateCommunityState(communityId, false);
}
}),
);
@ -406,9 +452,7 @@ const App: Component = () => {
// if the node itself has shut down (handled by stop_node command)
break;
case "sync_complete":
if (event.payload.community_id === activeCommunityId()) {
reloadCurrentChannel();
}
void handleSyncComplete(event.payload.community_id);
break;
case "profile_received":
// update our local directory cache when a peer announces their profile
@ -477,14 +521,18 @@ const App: Component = () => {
}
}
async function reloadCurrentChannel() {
const channelId = activeChannelId();
if (!channelId || !tauriAvailable()) return;
async function handleSyncComplete(communityId: string) {
if (!tauriAvailable()) return;
try {
const msgs = await tauri.getMessages(channelId);
setMessages(msgs);
const allCommunities = await tauri.getCommunities();
setCommunities(allCommunities);
} catch (e) {
console.error("failed to reload messages:", e);
console.error("failed to refresh communities after sync:", e);
}
if (communityId === activeCommunityId()) {
await hydrateCommunityState(communityId, true, true);
}
}

View File

@ -1,5 +1,13 @@
import type { Component } from "solid-js";
import { createSignal, createMemo, onMount, Show, For } from "solid-js";
import {
createSignal,
createMemo,
createEffect,
onCleanup,
onMount,
Show,
For,
} from "solid-js";
import {
Search,
X,
@ -13,76 +21,54 @@ import {
ChevronUp,
Loader2,
} from "lucide-solid";
import type { ChatMessage, DirectMessage } from "../../lib/types";
import type {
DMSearchFrom,
DMSearchMedia,
DirectMessage,
} from "../../lib/types";
import { formatTime, formatDaySeparator } from "../../lib/utils";
import { extractMentions } from "../../lib/mentions";
import * as tauri from "../../lib/tauri";
// regex patterns for detecting media in message content
const IMAGE_REGEX = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)(\?[^\s]*)?$/i;
const VIDEO_REGEX = /\.(mp4|webm|mov|avi|mkv)(\?[^\s]*)?$/i;
const LINK_REGEX = /https?:\/\/[^\s]+/i;
const FILE_REGEX = /\.(pdf|doc|docx|xls|xlsx|zip|rar|7z|tar|gz)(\?[^\s]*)?$/i;
// upper bound so we pull the entire conversation from disk
const ALL_MESSAGES_LIMIT = 1_000_000;
type MediaFilter = "images" | "videos" | "links" | "files";
type FilterFrom = "anyone" | "me" | "them";
const SEARCH_LIMIT = 300;
const RESULT_ROW_HEIGHT = 56;
const RESULT_OVERSCAN = 6;
interface DMSearchPanelProps {
peerId: string;
myPeerId: string;
peerName: string;
onClose: () => void;
onJumpToMessage: (messageId: string, allMessages: DirectMessage[]) => void;
onJumpToMessage: (messageId: string, timestamp: number) => void;
}
const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
const [query, setQuery] = createSignal("");
const [fromFilter, setFromFilter] = createSignal<FilterFrom>("anyone");
const [mediaFilter, setMediaFilter] = createSignal<MediaFilter | null>(null);
const [fromFilter, setFromFilter] = createSignal<DMSearchFrom>("anyone");
const [mediaFilter, setMediaFilter] = createSignal<DMSearchMedia | null>(null);
const [mentionsOnly, setMentionsOnly] = createSignal(false);
const [dateAfter, setDateAfter] = createSignal<string>("");
const [dateBefore, setDateBefore] = createSignal<string>("");
const [showFilters, setShowFilters] = createSignal(false);
// full conversation loaded from disk for searching
const [allMessages, setAllMessages] = createSignal<DirectMessage[]>([]);
const [loading, setLoading] = createSignal(true);
const [results, setResults] = createSignal<DirectMessage[]>([]);
const [loading, setLoading] = createSignal(false);
const [resultScrollTop, setResultScrollTop] = createSignal(0);
const [resultViewportHeight, setResultViewportHeight] = createSignal(320);
let inputRef: HTMLInputElement | undefined;
let resultsRef: HTMLDivElement | undefined;
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined;
let activeSearchId = 0;
// load entire conversation history from disk on mount
onMount(async () => {
try {
const msgs = await tauri.getDMMessages(
props.peerId,
undefined,
ALL_MESSAGES_LIMIT,
);
setAllMessages(msgs);
} catch (e) {
console.error("failed to load all dm messages for search:", e);
} finally {
setLoading(false);
// focus after loading completes
inputRef?.focus();
}
// focus the search field when the panel opens
onMount(() => {
inputRef?.focus();
});
// adapt DirectMessage[] to a searchable shape
const searchableMessages = createMemo((): ChatMessage[] =>
allMessages().map((m) => ({
id: m.id,
channel_id: `dm_${props.peerId}`,
author_id: m.from_peer,
author_name: m.from_display_name,
content: m.content,
timestamp: m.timestamp,
edited: false,
})),
);
onCleanup(() => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
activeSearchId += 1;
});
const hasActiveFilters = createMemo(() => {
return (
@ -94,50 +80,97 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
);
});
const filteredMessages = createMemo(() => {
const q = query().toLowerCase().trim();
createEffect(() => {
const textQuery = query().trim();
const from = fromFilter();
const media = mediaFilter();
const mentions = mentionsOnly();
const after = dateAfter();
const before = dateBefore();
const hasFilters = hasActiveFilters();
// no search or filters active, return nothing
if (!q && !hasActiveFilters()) return [];
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = undefined;
}
const afterTs = after ? new Date(after).getTime() : null;
const beforeTs = before
? new Date(before).getTime() + 86_400_000
: null;
if (!textQuery && !hasFilters) {
setLoading(false);
setResults([]);
return;
}
return searchableMessages().filter((msg) => {
// text query
if (q && !msg.content.toLowerCase().includes(q)) return false;
const searchId = ++activeSearchId;
setLoading(true);
// from filter
if (from === "me" && msg.author_id !== props.myPeerId) return false;
if (from === "them" && msg.author_id === props.myPeerId) return false;
searchDebounceTimer = setTimeout(async () => {
const dateAfterTs = after ? new Date(after).getTime() : null;
const dateBeforeTs = before
? new Date(before).getTime() + 86_399_999
: null;
// date range
if (afterTs && msg.timestamp < afterTs) return false;
if (beforeTs && msg.timestamp > beforeTs) return false;
try {
const nextResults = await tauri.searchDMMessages(props.peerId, {
query: textQuery || undefined,
from_filter: from,
media_filter: media,
mentions_only: mentions,
date_after: dateAfterTs,
date_before: dateBeforeTs,
limit: SEARCH_LIMIT,
});
// media type
if (media) {
const content = msg.content.trim();
if (media === "images" && !IMAGE_REGEX.test(content)) return false;
if (media === "videos" && !VIDEO_REGEX.test(content)) return false;
if (media === "links" && !LINK_REGEX.test(content)) return false;
if (media === "files" && !FILE_REGEX.test(content)) return false;
if (searchId !== activeSearchId) return;
setResultScrollTop(0);
if (resultsRef) {
resultsRef.scrollTop = 0;
setResultViewportHeight(resultsRef.clientHeight || 320);
}
setResults(nextResults);
} catch (error) {
if (searchId !== activeSearchId) return;
console.error("failed to search dm messages:", error);
setResults([]);
} finally {
if (searchId === activeSearchId) {
setLoading(false);
}
}
// mentions only
if (mentions && extractMentions(msg.content).length === 0) return false;
return true;
});
}, 120);
});
const totalResultHeight = createMemo(
() => results().length * RESULT_ROW_HEIGHT,
);
const visibleResults = createMemo(() => {
const rows = results();
const viewport = resultViewportHeight();
const scrollTop = resultScrollTop();
const startIndex = Math.max(
0,
Math.floor(scrollTop / RESULT_ROW_HEIGHT) - RESULT_OVERSCAN,
);
const endIndex = Math.min(
rows.length,
Math.ceil((scrollTop + viewport) / RESULT_ROW_HEIGHT) + RESULT_OVERSCAN,
);
const slice = rows.slice(startIndex, endIndex);
return slice.map((message, index) => ({
message,
index: startIndex + index,
}));
});
function handleResultScroll() {
if (!resultsRef) return;
setResultScrollTop(resultsRef.scrollTop);
setResultViewportHeight(resultsRef.clientHeight || 320);
}
function clearAllFilters() {
setQuery("");
setFromFilter("anyone");
@ -147,24 +180,20 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
setDateBefore("");
}
function handleJump(messageId: string) {
props.onJumpToMessage(messageId, allMessages());
function handleJump(message: DirectMessage) {
props.onJumpToMessage(message.id, message.timestamp);
}
// highlight matching text in a result snippet
function highlightMatch(text: string): string {
const q = query().trim();
if (!q) return escapeHtml(truncate(text, 120));
const textQuery = query().trim();
if (!textQuery) return escapeHtml(truncate(text, 120));
const escaped = escapeHtml(truncate(text, 120));
const regex = new RegExp(
`(${escapeRegex(escapeHtml(q))})`,
"gi",
);
return escaped.replace(
regex,
'<span class="text-orange font-medium">$1</span>',
);
const escapedQuery = escapeRegex(escapeHtml(textQuery));
const regex = new RegExp(`(${escapedQuery})`, "gi");
return escaped.replace(regex, '<span class="text-orange font-medium">$1</span>');
}
return (
@ -179,30 +208,33 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
>
<Search size={16} class="shrink-0 text-white/40" />
</Show>
<input
ref={inputRef}
type="text"
placeholder={loading() ? "loading messages..." : "search messages..."}
placeholder={loading() ? "searching..." : "search messages..."}
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
disabled={loading()}
class="flex-1 bg-transparent text-[14px] text-white placeholder:text-white/30 outline-none disabled:opacity-50"
onInput={(event) => setQuery(event.currentTarget.value)}
class="flex-1 bg-transparent text-[14px] text-white placeholder:text-white/30 outline-none"
/>
<Show when={!loading() && (query() || hasActiveFilters())}>
<span class="text-[12px] font-mono text-white/40 shrink-0">
{filteredMessages().length} result{filteredMessages().length !== 1 ? "s" : ""}
{results().length} result{results().length !== 1 ? "s" : ""}
</span>
</Show>
<button
type="button"
class="shrink-0 p-1 text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
onClick={() => setShowFilters((v) => !v)}
onClick={() => setShowFilters((value) => !value)}
aria-label="Toggle filters"
>
<Show when={showFilters()} fallback={<ChevronDown size={16} />}>
<ChevronUp size={16} />
</Show>
</button>
<button
type="button"
class="shrink-0 p-1 text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
@ -252,7 +284,9 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<FilterChip
active={mediaFilter() === "images"}
onClick={() =>
setMediaFilter((v) => (v === "images" ? null : "images"))
setMediaFilter((value) =>
value === "images" ? null : "images",
)
}
icon={<Image size={12} />}
label="images"
@ -260,7 +294,9 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<FilterChip
active={mediaFilter() === "videos"}
onClick={() =>
setMediaFilter((v) => (v === "videos" ? null : "videos"))
setMediaFilter((value) =>
value === "videos" ? null : "videos",
)
}
icon={<FileText size={12} />}
label="videos"
@ -268,7 +304,9 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<FilterChip
active={mediaFilter() === "links"}
onClick={() =>
setMediaFilter((v) => (v === "links" ? null : "links"))
setMediaFilter((value) =>
value === "links" ? null : "links",
)
}
icon={<Link size={12} />}
label="links"
@ -276,14 +314,16 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<FilterChip
active={mediaFilter() === "files"}
onClick={() =>
setMediaFilter((v) => (v === "files" ? null : "files"))
setMediaFilter((value) =>
value === "files" ? null : "files",
)
}
icon={<FileText size={12} />}
label="files"
/>
<FilterChip
active={mentionsOnly()}
onClick={() => setMentionsOnly((v) => !v)}
onClick={() => setMentionsOnly((value) => !value)}
icon={<AtSign size={12} />}
label="mentions"
/>
@ -301,7 +341,7 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<input
type="date"
value={dateAfter()}
onInput={(e) => setDateAfter(e.currentTarget.value)}
onInput={(event) => setDateAfter(event.currentTarget.value)}
class="bg-gray-800 text-[12px] font-mono text-white/60 px-2 py-1 border border-white/10 outline-none focus:border-orange transition-colors duration-200 [color-scheme:dark]"
placeholder="after"
/>
@ -311,7 +351,7 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<input
type="date"
value={dateBefore()}
onInput={(e) => setDateBefore(e.currentTarget.value)}
onInput={(event) => setDateBefore(event.currentTarget.value)}
class="bg-gray-800 text-[12px] font-mono text-white/60 px-2 py-1 border border-white/10 outline-none focus:border-orange transition-colors duration-200 [color-scheme:dark]"
placeholder="before"
/>
@ -320,7 +360,7 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
</div>
{/* clear all */}
<Show when={hasActiveFilters()}>
<Show when={hasActiveFilters() || query()}>
<button
type="button"
class="self-start text-[11px] font-mono text-orange hover:text-orange-hover transition-colors duration-200 cursor-pointer"
@ -333,40 +373,56 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
</Show>
{/* search results */}
<Show when={!loading() && (query() || hasActiveFilters())}>
<div class="max-h-[320px] overflow-y-auto border-t border-white/5">
<Show when={query() || hasActiveFilters()}>
<div
ref={resultsRef}
class="max-h-[320px] overflow-y-auto border-t border-white/5"
onScroll={handleResultScroll}
>
<Show
when={filteredMessages().length > 0}
when={!loading() && results().length > 0}
fallback={
<div class="px-4 py-6 text-center text-[13px] text-white/30">
no messages found
<Show when={loading()} fallback={<>no messages found</>}>
searching messages
</Show>
</div>
}
>
<For each={filteredMessages()}>
{(msg) => (
<button
type="button"
class="w-full px-4 py-2 flex items-start gap-3 text-left hover:bg-gray-800 transition-colors duration-200 cursor-pointer group"
onClick={() => handleJump(msg.id)}
>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="text-[13px] font-medium text-white/80 group-hover:text-white truncate">
{msg.author_name}
</span>
<span class="text-[11px] font-mono text-white/30">
{formatDaySeparator(msg.timestamp)} {formatTime(msg.timestamp)}
</span>
<div
class="relative"
style={{ height: `${Math.max(totalResultHeight(), 1)}px` }}
>
<For each={visibleResults()}>
{(row) => (
<button
type="button"
class="w-full px-4 flex items-start gap-3 text-left hover:bg-gray-800 transition-colors duration-200 cursor-pointer group absolute left-0 right-0"
style={{
top: `${row.index * RESULT_ROW_HEIGHT}px`,
height: `${RESULT_ROW_HEIGHT}px`,
}}
onClick={() => handleJump(row.message)}
>
<div class="flex-1 min-w-0 pt-2">
<div class="flex items-baseline gap-2">
<span class="text-[13px] font-medium text-white/80 group-hover:text-white truncate">
{row.message.from_display_name}
</span>
<span class="text-[11px] font-mono text-white/30">
{formatDaySeparator(row.message.timestamp)}{" "}
{formatTime(row.message.timestamp)}
</span>
</div>
<p
class="text-[13px] text-white/50 truncate mt-0.5"
innerHTML={highlightMatch(row.message.content)}
/>
</div>
<p
class="text-[13px] text-white/50 truncate mt-0.5"
innerHTML={highlightMatch(msg.content)}
/>
</div>
</button>
)}
</For>
</button>
)}
</For>
</div>
</Show>
</div>
</Show>
@ -374,7 +430,6 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
);
};
// reusable filter chip
interface FilterChipProps {
active: boolean;
onClick: () => void;
@ -397,13 +452,12 @@ const FilterChip: Component<FilterChipProps> = (props) => (
</button>
);
// utilities
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
.replace(/\"/g, "&quot;");
}
function escapeRegex(str: string): string {

View File

@ -0,0 +1,451 @@
import type { Component } from "solid-js";
import {
For,
Show,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
onMount,
untrack,
} from "solid-js";
import type { ChatMessage } from "../../lib/types";
import {
isWithinGroupWindow,
isDifferentDay,
formatDaySeparator,
} from "../../lib/utils";
import Message from "./Message";
import { ArrowDown } from "lucide-solid";
interface VirtualMessageListProps {
messages: ChatMessage[];
conversationKey: string;
focusMessageId?: string | null;
onLoadMore?: () => void;
}
interface MessageRenderMeta {
message: ChatMessage;
isGrouped: boolean;
isFirstInGroup: boolean;
isLastInGroup: boolean;
showDaySeparator: boolean;
isLastMessage: boolean;
}
interface VirtualRow {
key: string;
type: "separator" | "message";
top: number;
height: number;
separatorLabel?: string;
meta?: MessageRenderMeta;
}
const OVERSCAN_PX = 600;
const DAY_SEPARATOR_ESTIMATE = 42;
const VirtualMessageList: Component<VirtualMessageListProps> = (props) => {
let containerRef: HTMLDivElement | undefined;
let rowResizeObserver: ResizeObserver | undefined;
let clearHighlightTimer: ReturnType<typeof setTimeout> | undefined;
const [scrollTop, setScrollTop] = createSignal(0);
const [viewportHeight, setViewportHeight] = createSignal(0);
const [showScrollButton, setShowScrollButton] = createSignal(false);
const [isAtBottom, setIsAtBottom] = createSignal(true);
const [prevMessageCount, setPrevMessageCount] = createSignal(0);
const [shouldAnimateLast, setShouldAnimateLast] = createSignal(false);
const [measuredHeights, setMeasuredHeights] = createSignal<
Record<string, number>
>({});
const [highlightedMessageId, setHighlightedMessageId] = createSignal<
string | null
>(null);
let lastLoadRequestAt = 0;
let pendingPrependCompensation:
| {
totalHeight: number;
scrollTop: number;
oldestMessageId: string | null;
}
| null = null;
const messageMeta = createMemo((): MessageRenderMeta[] => {
const messages = props.messages;
return messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
const next =
index < messages.length - 1 ? messages[index + 1] : undefined;
const isFirstInGroup =
!prev ||
prev.author_id !== message.author_id ||
!isWithinGroupWindow(prev.timestamp, message.timestamp);
const isLastInGroup =
!next ||
next.author_id !== message.author_id ||
!isWithinGroupWindow(message.timestamp, next.timestamp);
const showDaySeparator =
!prev || isDifferentDay(prev.timestamp, message.timestamp);
return {
message,
isGrouped: !isFirstInGroup,
isFirstInGroup,
isLastInGroup,
showDaySeparator,
isLastMessage: index === messages.length - 1,
};
});
});
const rows = createMemo(() => {
const heights = measuredHeights();
const rendered = messageMeta();
const virtualRows: VirtualRow[] = [];
let cursorTop = 0;
for (const meta of rendered) {
if (meta.showDaySeparator) {
const rowKey = `sep:${meta.message.id}`;
const height = heights[rowKey] ?? DAY_SEPARATOR_ESTIMATE;
virtualRows.push({
key: rowKey,
type: "separator",
top: cursorTop,
height,
separatorLabel: formatDaySeparator(meta.message.timestamp),
});
cursorTop += height;
}
const rowKey = `msg:${meta.message.id}`;
const estimatedHeight = estimateMessageHeight(
meta.message.content,
meta.isFirstInGroup,
);
const height = heights[rowKey] ?? estimatedHeight;
virtualRows.push({
key: rowKey,
type: "message",
top: cursorTop,
height,
meta,
});
cursorTop += height;
}
return {
items: virtualRows,
totalHeight: cursorTop,
};
});
const visibleRows = createMemo(() => {
const allRows = rows().items;
if (allRows.length === 0) return [];
const startY = Math.max(0, scrollTop() - OVERSCAN_PX);
const endY = scrollTop() + viewportHeight() + OVERSCAN_PX;
const startIndex = Math.max(0, findFirstVisibleRowIndex(allRows, startY) - 2);
let endIndex = startIndex;
while (endIndex < allRows.length && allRows[endIndex].top < endY) {
endIndex += 1;
}
return allRows.slice(startIndex, Math.min(allRows.length, endIndex + 2));
});
function setMeasuredHeight(rowKey: string, nextHeight: number) {
const roundedHeight = Math.ceil(nextHeight);
if (roundedHeight <= 0) return;
setMeasuredHeights((prev) => {
if (prev[rowKey] === roundedHeight) return prev;
return { ...prev, [rowKey]: roundedHeight };
});
}
function observeRow(el: HTMLDivElement, rowKey: string) {
el.dataset.virtualKey = rowKey;
rowResizeObserver?.observe(el);
}
function syncViewportMetrics() {
if (!containerRef) return;
setViewportHeight(containerRef.clientHeight);
}
function scrollToBottom(smooth = true) {
if (!containerRef) return;
containerRef.scrollTo({
top: rows().totalHeight,
behavior: smooth ? "smooth" : "auto",
});
}
function scrollToMessage(messageId: string) {
if (!containerRef) return;
const messageRow = rows().items.find(
(row) => row.type === "message" && row.meta?.message.id === messageId,
);
if (!messageRow) return;
const targetTop = Math.max(
0,
messageRow.top - Math.floor(containerRef.clientHeight * 0.35),
);
containerRef.scrollTo({ top: targetTop, behavior: "smooth" });
setHighlightedMessageId(messageId);
if (clearHighlightTimer) {
clearTimeout(clearHighlightTimer);
}
clearHighlightTimer = setTimeout(() => {
setHighlightedMessageId(null);
}, 2000);
}
function maybeLoadOlderMessages() {
if (!props.onLoadMore || !containerRef) return;
if (containerRef.scrollTop > 120) return;
const now = Date.now();
if (now - lastLoadRequestAt < 400) return;
lastLoadRequestAt = now;
pendingPrependCompensation = {
totalHeight: rows().totalHeight,
scrollTop: containerRef.scrollTop,
oldestMessageId: props.messages[0]?.id ?? null,
};
props.onLoadMore();
}
function handleScroll() {
if (!containerRef) return;
const currentScrollTop = containerRef.scrollTop;
setScrollTop(currentScrollTop);
const distanceFromBottom =
rows().totalHeight - currentScrollTop - containerRef.clientHeight;
const atBottom = distanceFromBottom < 64;
setIsAtBottom(atBottom);
setShowScrollButton(!atBottom);
maybeLoadOlderMessages();
}
createEffect(() => {
const currentCount = props.messages.length;
const prevCount = untrack(() => prevMessageCount());
if (currentCount > prevCount && prevCount > 0) {
setShouldAnimateLast(true);
} else {
setShouldAnimateLast(false);
}
setPrevMessageCount(currentCount);
});
createEffect(() => {
const messageCount = props.messages.length;
if (messageCount === 0) return;
if (isAtBottom()) {
requestAnimationFrame(() => scrollToBottom(true));
}
});
createEffect(() => {
const totalHeight = rows().totalHeight;
if (!containerRef || !pendingPrependCompensation) return;
const currentOldestMessageId = props.messages[0]?.id ?? null;
if (currentOldestMessageId === pendingPrependCompensation.oldestMessageId) {
pendingPrependCompensation = null;
return;
}
if (totalHeight <= pendingPrependCompensation.totalHeight) return;
const delta = totalHeight - pendingPrependCompensation.totalHeight;
containerRef.scrollTop = pendingPrependCompensation.scrollTop + delta;
pendingPrependCompensation = null;
});
createEffect(
on(
() => props.focusMessageId,
(messageId) => {
if (!messageId) return;
requestAnimationFrame(() => scrollToMessage(messageId));
},
),
);
createEffect(
on(
() => props.conversationKey,
() => {
setMeasuredHeights({});
setHighlightedMessageId(null);
setShowScrollButton(false);
setIsAtBottom(true);
pendingPrependCompensation = null;
requestAnimationFrame(() => {
scrollToBottom(false);
handleScroll();
});
},
),
);
onMount(() => {
rowResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const el = entry.target as HTMLElement;
const rowKey = el.dataset.virtualKey;
if (!rowKey) continue;
setMeasuredHeight(rowKey, entry.contentRect.height);
}
});
syncViewportMetrics();
requestAnimationFrame(() => {
scrollToBottom(false);
handleScroll();
});
window.addEventListener("resize", syncViewportMetrics);
});
onCleanup(() => {
window.removeEventListener("resize", syncViewportMetrics);
rowResizeObserver?.disconnect();
if (clearHighlightTimer) {
clearTimeout(clearHighlightTimer);
}
});
return (
<div class="relative flex-1 min-h-0">
<div
ref={containerRef}
class="h-full overflow-y-auto"
onScroll={handleScroll}
>
<div
class="relative"
style={{ height: `${Math.max(rows().totalHeight, 1)}px` }}
>
<For each={visibleRows()}>
{(row) => (
<div
ref={(el) => observeRow(el, row.key)}
class="absolute left-0 right-0"
style={{ transform: `translateY(${row.top}px)` }}
>
<Show when={row.type === "separator"}>
<div class="flex items-center gap-4 px-4 my-4">
<div class="flex-1 border-t border-white/10" />
<span class="text-[12px] font-mono text-white/40 uppercase tracking-[0.05em]">
{row.separatorLabel}
</span>
<div class="flex-1 border-t border-white/10" />
</div>
</Show>
<Show when={row.type === "message" && row.meta}>
<div
data-message-id={row.meta?.message.id}
class={
row.meta?.isLastMessage && shouldAnimateLast()
? "animate-message-in"
: ""
}
>
<div
class={
highlightedMessageId() === row.meta?.message.id
? "dusk-msg-search-highlight"
: ""
}
>
<Message
message={row.meta!.message}
isGrouped={row.meta!.isGrouped}
isFirstInGroup={row.meta!.isFirstInGroup}
isLastInGroup={row.meta!.isLastInGroup}
/>
</div>
</div>
</Show>
</div>
)}
</For>
</div>
</div>
<Show when={showScrollButton()}>
<button
type="button"
class="absolute bottom-4 right-4 w-10 h-10 bg-orange rounded-full flex items-center justify-center text-white shadow-lg hover:bg-orange-hover transition-all duration-200 cursor-pointer animate-scale-in"
onClick={() => scrollToBottom(true)}
>
<ArrowDown size={20} />
</button>
</Show>
</div>
);
};
function estimateMessageHeight(content: string, isFirstInGroup: boolean): number {
const baseHeight = isFirstInGroup ? 82 : 46;
const charLines = Math.max(0, Math.ceil(content.length / 90) - 1);
const newlineLines = Math.max(0, content.split("\n").length - 1);
const extraLines = Math.min(8, charLines + newlineLines);
return baseHeight + extraLines * 18;
}
function findFirstVisibleRowIndex(rows: VirtualRow[], offset: number): number {
let low = 0;
let high = rows.length - 1;
let best = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const row = rows[mid];
if (row.top + row.height < offset) {
low = mid + 1;
} else {
best = mid;
high = mid - 1;
}
}
return best;
}
export default VirtualMessageList;

View File

@ -1,40 +1,60 @@
import type { Component } from "solid-js";
import { Show, createMemo, createSignal } from "solid-js";
import { Show, createMemo, createSignal, createEffect, on } from "solid-js";
import { Phone, Pin, Search } from "lucide-solid";
import {
activeDMConversation,
dmMessages,
dmTypingPeers,
prependDMMessages,
setDMMessages,
} from "../../stores/dms";
import { onlinePeerIds } from "../../stores/members";
import { identity } from "../../stores/identity";
import MessageList from "../chat/MessageList";
import VirtualMessageList from "../chat/VirtualMessageList";
import MessageInput from "../chat/MessageInput";
import TypingIndicator from "../chat/TypingIndicator";
import DMSearchPanel from "../chat/DMSearchPanel";
import Avatar from "../common/Avatar";
import IconButton from "../common/IconButton";
import type { ChatMessage, DirectMessage } from "../../lib/types";
import type { ChatMessage } from "../../lib/types";
import * as tauri from "../../lib/tauri";
interface DMChatAreaProps {
onSendDM: (content: string) => void;
onTyping: () => void;
}
const HISTORY_PAGE_SIZE = 80;
const JUMP_WINDOW_SIZE = 500;
const DMChatArea: Component<DMChatAreaProps> = (props) => {
const [searchOpen, setSearchOpen] = createSignal(false);
const [focusMessageId, setFocusMessageId] = createSignal<string | null>(null);
const [loadingHistory, setLoadingHistory] = createSignal(false);
const [hasMoreHistory, setHasMoreHistory] = createSignal(true);
const dm = () => activeDMConversation();
// adapt DirectMessage[] to ChatMessage[] so the existing MessageList works
createEffect(
on(
() => dm()?.peer_id,
() => {
setFocusMessageId(null);
setLoadingHistory(false);
setHasMoreHistory(true);
},
),
);
// adapt direct messages to chat message shape so we can share rendering logic
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,
dmMessages().map((message) => ({
id: message.id,
channel_id: `dm_${message.from_peer === dm()?.peer_id ? message.from_peer : message.to_peer}`,
author_id: message.from_peer,
author_name: message.from_display_name,
content: message.content,
timestamp: message.timestamp,
edited: false,
})),
);
@ -47,29 +67,75 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
return "offline";
});
// scroll to a message by id, loading full history into the store if needed
function handleJumpToMessage(
messageId: string,
allMessages: DirectMessage[],
) {
const alreadyLoaded = dmMessages().some((m) => m.id === messageId);
function focusMessage(messageId: string) {
setFocusMessageId(null);
requestAnimationFrame(() => {
setFocusMessageId(messageId);
});
}
if (!alreadyLoaded) {
// replace the store with the full history so the target is in the dom
setDMMessages(allMessages);
async function loadOlderMessages() {
const peerId = dm()?.peer_id;
if (!peerId) return;
if (loadingHistory() || !hasMoreHistory()) return;
const currentMessages = dmMessages();
if (currentMessages.length === 0) return;
const oldestTimestamp = currentMessages[0].timestamp;
setLoadingHistory(true);
try {
const olderMessages = await tauri.getDMMessages(
peerId,
oldestTimestamp,
HISTORY_PAGE_SIZE,
);
if (olderMessages.length === 0) {
setHasMoreHistory(false);
return;
}
prependDMMessages(olderMessages);
if (olderMessages.length < HISTORY_PAGE_SIZE) {
setHasMoreHistory(false);
}
} catch (error) {
console.error("failed to load older dm messages:", error);
} finally {
setLoadingHistory(false);
}
}
// scroll to a message by id and lazy-load a focused history window if needed
async function handleJumpToMessage(messageId: string, timestamp: number) {
const peerId = dm()?.peer_id;
if (!peerId) return;
const alreadyLoaded = dmMessages().some((message) => message.id === messageId);
if (alreadyLoaded) {
focusMessage(messageId);
return;
}
// wait for the dom to update then scroll and highlight
requestAnimationFrame(() => {
const el = document.querySelector(
`[data-message-id="${messageId}"]`,
) as HTMLElement | null;
if (!el) return;
try {
const aroundTarget = await tauri.getDMMessages(
peerId,
timestamp + 1,
JUMP_WINDOW_SIZE,
);
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("dusk-msg-search-highlight");
setTimeout(() => el.classList.remove("dusk-msg-search-highlight"), 2000);
});
if (aroundTarget.length > 0) {
setDMMessages(aroundTarget);
setHasMoreHistory(aroundTarget.length >= JUMP_WINDOW_SIZE);
}
focusMessage(messageId);
} catch (error) {
console.error("failed to jump to dm search result:", error);
}
}
// typing indicator names
@ -78,7 +144,7 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
if (typing.length === 0) return [];
const peer = dm();
if (!peer) return [];
// for dms there's only ever one person who can be typing
// for dms theres only ever one person who can be typing
return typing.includes(peer.peer_id) ? [peer.display_name] : [];
});
@ -105,7 +171,7 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
<IconButton
label="Search messages"
active={searchOpen()}
onClick={() => setSearchOpen((v) => !v)}
onClick={() => setSearchOpen((value) => !value)}
>
<Search size={18} />
</IconButton>
@ -123,7 +189,6 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
<Show when={searchOpen() && dm()}>
<DMSearchPanel
peerId={dm()!.peer_id}
myPeerId={identity()?.peer_id ?? ""}
peerName={dm()!.display_name}
onClose={() => setSearchOpen(false)}
onJumpToMessage={handleJumpToMessage}
@ -148,7 +213,12 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
</div>
}
>
<MessageList messages={adaptedMessages()} />
<VirtualMessageList
messages={adaptedMessages()}
conversationKey={dm()?.peer_id ?? ""}
focusMessageId={focusMessageId()}
onLoadMore={loadOlderMessages}
/>
</Show>
{/* typing indicator */}

View File

@ -6,28 +6,38 @@ import {
activeCommunityId,
setActiveCommunity,
} from "../../stores/communities";
import { setActiveDM } from "../../stores/dms";
import { dmConversations, setActiveDM } from "../../stores/dms";
import { getInitials, hashColor } from "../../lib/utils";
import { openModal } from "../../stores/ui";
const ServerList: Component = () => {
const unreadDMCount = () =>
dmConversations().reduce((total, dm) => total + dm.unread_count, 0);
return (
<div class="w-16 shrink-0 border-r bg-black flex flex-col items-center py-3 gap-2 overflow-y-auto no-select">
{/* home button */}
<button
type="button"
class={`w-12 h-12 flex items-center justify-center transition-all duration-200 cursor-pointer ${
activeCommunityId() === null
? "bg-orange text-white"
: "bg-gray-800 text-white/60 hover:bg-gray-800 hover:text-white hover:scale-105"
}`}
onClick={() => {
setActiveCommunity(null);
setActiveDM(null);
}}
>
<Home size={24} />
</button>
<div class="relative">
<button
type="button"
class={`w-12 h-12 flex items-center justify-center transition-all duration-200 cursor-pointer ${
activeCommunityId() === null
? "bg-orange text-white"
: "bg-gray-800 text-white/60 hover:bg-gray-800 hover:text-white hover:scale-105"
}`}
onClick={() => {
setActiveCommunity(null);
setActiveDM(null);
}}
>
<Home size={24} />
</button>
<Show when={unreadDMCount() > 0}>
<div class="absolute -top-1 -right-1 min-w-5 h-5 px-1 bg-orange text-white text-[11px] leading-none font-bold flex items-center justify-center rounded-full">
{unreadDMCount() > 99 ? "99+" : unreadDMCount()}
</div>
</Show>
</div>
<div class="w-8 border-t border-white/20 my-1" />

View File

@ -15,6 +15,7 @@ import type {
VoiceMediaState,
DirectMessage,
DMConversationMeta,
DMSearchFilters,
GifResponse,
} from "./types";
@ -374,6 +375,22 @@ export async function getDMMessages(
return invoke("get_dm_messages", { peerId, before, limit });
}
export async function searchDMMessages(
peerId: string,
filters: DMSearchFilters,
): Promise<DirectMessage[]> {
return invoke("search_dm_messages", {
peerId,
query: filters.query,
fromFilter: filters.from_filter,
mediaFilter: filters.media_filter,
mentionsOnly: filters.mentions_only,
dateAfter: filters.date_after,
dateBefore: filters.date_before,
limit: filters.limit,
});
}
export async function getDMConversations(): Promise<DMConversationMeta[]> {
return invoke("get_dm_conversations");
}

View File

@ -125,6 +125,19 @@ export interface DMConversationMeta {
unread_count: number;
}
export type DMSearchFrom = "anyone" | "me" | "them";
export type DMSearchMedia = "images" | "videos" | "links" | "files";
export interface DMSearchFilters {
query?: string;
from_filter?: DMSearchFrom;
media_filter?: DMSearchMedia | null;
mentions_only?: boolean;
date_after?: number | null;
date_before?: number | null;
limit?: number;
}
export interface Member {
peer_id: string;
display_name: string;

View File

@ -8,7 +8,16 @@ const [activeCommunityId, setActiveCommunityId] = createSignal<string | null>(
);
export function addCommunity(community: CommunityMeta) {
setCommunities((prev) => [...prev, community]);
setCommunities((prev) => {
const existingIndex = prev.findIndex((item) => item.id === community.id);
if (existingIndex === -1) {
return [...prev, community];
}
const next = [...prev];
next[existingIndex] = community;
return next;
});
}
export function removeCommunity(id: string) {

View File

@ -61,7 +61,12 @@ export function clearDMUnread(peerId: string) {
}
export function addDMMessage(message: DirectMessage) {
setDMMessages((prev) => [...prev, message]);
setDMMessages((prev) => mergeUniqueMessages([...prev, message]));
}
export function prependDMMessages(messages: DirectMessage[]) {
if (messages.length === 0) return;
setDMMessages((prev) => mergeUniqueMessages([...messages, ...prev]));
}
export function clearDMMessages() {
@ -143,3 +148,16 @@ export {
setDMConversations,
setDMMessages,
};
function mergeUniqueMessages(messages: DirectMessage[]): DirectMessage[] {
const seen = new Set<string>();
const merged: DirectMessage[] = [];
for (const message of messages) {
if (seen.has(message.id)) continue;
seen.add(message.id);
merged.push(message);
}
return merged;
}