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", "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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@ -1287,6 +1299,7 @@ dependencies = [
"libp2p", "libp2p",
"log", "log",
"rand 0.8.5", "rand 0.8.5",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -1473,6 +1486,18 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@ -2058,6 +2083,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -2075,6 +2109,15 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 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]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@ -3318,6 +3361,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "linked-hash-map" name = "linked-hash-map"
version = "0.5.6" version = "0.5.6"
@ -4944,6 +4998,20 @@ dependencies = [
"tokio", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@ -6559,6 +6627,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"

View File

@ -52,6 +52,7 @@ hex = "0.4"
# data storage # data storage
directories = "5" directories = "5"
rusqlite = { version = "0.32", features = ["bundled"] }
# env file support # env file support
dotenvy = "0.15" 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 namespace = format!("dusk/community/{}", community_id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::RegisterRendezvous { namespace }) .send(NodeCommand::RegisterRendezvous {
namespace: namespace.clone(),
})
.await;
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous { namespace })
.await; .await;
} }

View File

@ -3,6 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tauri::State; use tauri::State;
use crate::crdt::sync::{DocumentSnapshot, SyncMessage};
use crate::node::gossip; use crate::node::gossip;
use crate::node::NodeCommand; use crate::node::NodeCommand;
use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, CommunityMeta, Member}; 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 // helper to broadcast a crdt change to peers via the sync topic
async fn broadcast_sync(state: &State<'_, AppState>, community_id: &str) { 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; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
let sync_topic = "dusk/sync".to_string();
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { .send(NodeCommand::SendMessage {
topic: sync_topic, topic: gossip::topic_for_sync(),
data: community_id.as_bytes().to_vec(), 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; .await;
} }
@ -131,20 +178,16 @@ pub async fn join_community(
let invite = crate::protocol::community::InviteCode::decode(&invite_code)?; let invite = crate::protocol::community::InviteCode::decode(&invite_code)?;
let identity = state.identity.lock().await; let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?; if identity.is_none() {
let peer_id_str = id.peer_id.to_string(); return Err("no identity loaded".to_string());
}
drop(identity); drop(identity);
// create a placeholder document that will be backfilled via crdt sync // create a placeholder document that will be backfilled via crdt sync
// once we connect to existing community members through the relay // once we connect to existing community members through the relay
let mut engine = state.crdt_engine.lock().await; let mut engine = state.crdt_engine.lock().await;
if !engine.has_community(&invite.community_id) { if !engine.has_community(&invite.community_id) {
engine.create_community( engine.create_placeholder_community(&invite.community_id, &invite.community_name, "")?;
&invite.community_id,
&invite.community_name,
"",
&peer_id_str,
)?;
} }
let meta = engine.get_community_meta(&invite.community_id)?; let meta = engine.get_community_meta(&invite.community_id)?;
@ -200,6 +243,9 @@ pub async fn join_community(
.await; .await;
} }
// request a snapshot now so joins work even when peers were already connected
request_sync(&state).await;
Ok(meta) Ok(meta)
} }
@ -208,11 +254,36 @@ pub async fn leave_community(
state: State<'_, AppState>, state: State<'_, AppState>,
community_id: String, community_id: String,
) -> Result<(), 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; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { 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 { for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id); let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle let _ = handle
@ -228,7 +299,6 @@ pub async fn leave_community(
}) })
.await; .await;
} }
}
let presence_topic = gossip::topic_for_presence(&community_id); let presence_topic = gossip::topic_for_presence(&community_id);
let _ = handle let _ = handle
@ -237,8 +307,18 @@ pub async fn leave_community(
topic: presence_topic, topic: presence_topic,
}) })
.await; .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(()) Ok(())
} }
@ -313,6 +393,8 @@ pub async fn create_channel(
.await; .await;
} }
broadcast_sync(&state, &community_id).await;
Ok(channel) Ok(channel)
} }
@ -353,18 +435,7 @@ pub async fn create_category(
engine.create_category(&community_id, &category)?; engine.create_category(&community_id, &category)?;
drop(engine); drop(engine);
// broadcast the change via document sync broadcast_sync(&state, &community_id).await;
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) Ok(category)
} }

View File

@ -7,6 +7,7 @@ use crate::node::NodeCommand;
use crate::protocol::messages::{ use crate::protocol::messages::{
DMConversationMeta, DMTypingIndicator, DirectMessage, GossipMessage, DMConversationMeta, DMTypingIndicator, DirectMessage, GossipMessage,
}; };
use crate::storage::DmSearchParams;
use crate::AppState; use crate::AppState;
// send a direct message to a peer // 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)) .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 // load all dm conversations for the sidebar
#[tauri::command] #[tauri::command]
pub async fn get_dm_conversations( 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; let mut node_handle = state.node_handle.lock().await;
if let Some(handle) = node_handle.take() { 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; 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 // 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 // used for notification icons so the os can display the user's avatar
#[tauri::command] #[tauri::command]
pub async fn cache_avatar_icon( pub async fn cache_avatar_icon(cache_key: String, svg_content: String) -> Result<String, String> {
cache_key: String,
svg_content: String,
) -> Result<String, String> {
let cache_dir = std::env::temp_dir().join("dusk-avatars"); let cache_dir = std::env::temp_dir().join("dusk-avatars");
std::fs::create_dir_all(&cache_dir) std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("failed to create avatar cache dir: {}", e))?; .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(), display_name: display_name.clone(),
media_state: media_state.clone(), media_state: media_state.clone(),
}; };
let data = let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { .send(NodeCommand::SendMessage {
@ -95,8 +94,7 @@ pub async fn leave_voice_channel(
channel_id: channel_id.clone(), channel_id: channel_id.clone(),
peer_id: peer_id.clone(), peer_id: peer_id.clone(),
}; };
let data = let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { .send(NodeCommand::SendMessage {
@ -108,9 +106,7 @@ pub async fn leave_voice_channel(
// unsubscribe from the voice topic // unsubscribe from the voice topic
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Unsubscribe { .send(NodeCommand::Unsubscribe { topic: voice_topic })
topic: voice_topic,
})
.await; .await;
} }
@ -151,8 +147,7 @@ pub async fn update_voice_media_state(
peer_id: peer_id.clone(), peer_id: peer_id.clone(),
media_state: media_state.clone(), media_state: media_state.clone(),
}; };
let data = let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { .send(NodeCommand::SendMessage {
@ -200,8 +195,7 @@ pub async fn send_voice_sdp(
sdp_type, sdp_type,
sdp, sdp,
}; };
let data = let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { .send(NodeCommand::SendMessage {
@ -241,8 +235,7 @@ pub async fn send_voice_ice_candidate(
sdp_mid, sdp_mid,
sdp_mline_index, sdp_mline_index,
}; };
let data = let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { .send(NodeCommand::SendMessage {

View File

@ -50,6 +50,32 @@ pub fn init_community_doc(
Ok(()) 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 // add a new channel to the community document
pub fn add_channel( pub fn add_channel(
doc: &mut AutoCommit, doc: &mut AutoCommit,

View File

@ -64,6 +64,22 @@ impl CrdtEngine {
Ok(()) 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 // add a channel to an existing community
pub fn create_channel( pub fn create_channel(
&mut self, &mut self,
@ -189,6 +205,18 @@ impl CrdtEngine {
self.documents.contains_key(community_id) 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 // save a document to disk
pub fn persist(&mut self, community_id: &str) -> Result<(), String> { pub fn persist(&mut self, community_id: &str) -> Result<(), String> {
let doc = self let doc = self
@ -350,11 +378,7 @@ impl CrdtEngine {
} }
// remove a category and ungroup its channels // remove a category and ungroup its channels
pub fn delete_category( pub fn delete_category(&mut self, community_id: &str, category_id: &str) -> Result<(), String> {
&mut self,
community_id: &str,
category_id: &str,
) -> Result<(), String> {
let doc = self let doc = self
.documents .documents
.get_mut(community_id) .get_mut(community_id)

View File

@ -19,6 +19,7 @@ use axum::Router;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::crdt::sync::{DocumentSnapshot, SyncMessage};
use crate::crdt::CrdtEngine; use crate::crdt::CrdtEngine;
use crate::node::gossip; use crate::node::gossip;
use crate::node::NodeCommand; use crate::node::NodeCommand;
@ -134,6 +135,67 @@ fn now_ms() -> u64 {
.as_millis() as 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 // find the community that owns a given channel
fn find_community_for_channel( fn find_community_for_channel(
engine: &crate::crdt::CrdtEngine, engine: &crate::crdt::CrdtEngine,
@ -405,21 +467,18 @@ async fn join_community(
.map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?; .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
let identity = state.identity.lock().await; let identity = state.identity.lock().await;
let id = identity if identity.is_none() {
.as_ref() return Err(ApiError(
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "no identity loaded".into()))?; StatusCode::UNAUTHORIZED,
let peer_id_str = id.peer_id.to_string(); "no identity loaded".into(),
));
}
drop(identity); drop(identity);
let mut engine = state.crdt_engine.lock().await; let mut engine = state.crdt_engine.lock().await;
if !engine.has_community(&invite.community_id) { if !engine.has_community(&invite.community_id) {
engine engine
.create_community( .create_placeholder_community(&invite.community_id, &invite.community_name, "")
&invite.community_id,
&invite.community_name,
"",
&peer_id_str,
)
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))?; .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))?;
} }
@ -473,6 +532,8 @@ async fn join_community(
.await; .await;
} }
request_sync(&state).await;
Ok(Json(meta)) Ok(Json(meta))
} }
@ -480,10 +541,36 @@ async fn leave_community(
State(state): State<DevState>, State(state): State<DevState>,
Path(community_id): Path<String>, Path(community_id): Path<String>,
) -> ApiResult<serde_json::Value> { ) -> 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; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { 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 { for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id); let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle let _ = handle
@ -499,7 +586,6 @@ async fn leave_community(
}) })
.await; .await;
} }
}
let presence_topic = gossip::topic_for_presence(&community_id); let presence_topic = gossip::topic_for_presence(&community_id);
let _ = handle let _ = handle
@ -508,8 +594,19 @@ async fn leave_community(
topic: presence_topic, topic: presence_topic,
}) })
.await; .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 }))) Ok(Json(serde_json::json!({ "ok": true })))
} }
@ -631,6 +728,8 @@ async fn create_channel(
.await; .await;
} }
broadcast_sync(&state, &community_id).await;
Ok(Json(channel)) 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 namespace = format!("dusk/community/{}", community_id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::RegisterRendezvous { namespace }) .send(NodeCommand::RegisterRendezvous {
namespace: namespace.clone(),
})
.await;
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous { namespace })
.await; .await;
} }

View File

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

View File

@ -94,6 +94,10 @@ pub enum NodeCommand {
DiscoverRendezvous { DiscoverRendezvous {
namespace: String, 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 // send a gif search request to the relay peer via request-response
GifSearch { GifSearch {
request: crate::protocol::gif::GifRequest, request: crate::protocol::gif::GifRequest,
@ -379,14 +383,50 @@ pub async fn start(
continue; 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(()) => { 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 { let _ = app_handle.emit("dusk-event", DuskEvent::SyncComplete {
community_id: snapshot.community_id, community_id,
}); });
} }
Err(e) => { 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); 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 }) => { Some(NodeCommand::GifSearch { request, reply }) => {
if let Some(rp) = relay_peer { if let Some(rp) = relay_peer {
let request_id = swarm_instance let request_id = swarm_instance

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -60,13 +60,17 @@ fn score_timing_variance(segments: &[SegmentData]) -> f64 {
return 0.0; 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; let mean = intervals.iter().sum::<f64>() / intervals.len() as f64;
if mean == 0.0 { if mean == 0.0 {
return 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; let cv = variance.sqrt() / mean;
// humans have natural variance in click timing // humans have natural variance in click timing
@ -286,8 +290,8 @@ pub fn generate_proof(
} }
// hash the raw challenge data to create a fingerprint // hash the raw challenge data to create a fingerprint
let challenge_bytes = let challenge_bytes = serde_json::to_vec(challenge)
serde_json::to_vec(challenge).map_err(|e| format!("failed to serialize challenge: {}", e))?; .map_err(|e| format!("failed to serialize challenge: {}", e))?;
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(&challenge_bytes); hasher.update(&challenge_bytes);
let metrics_hash = hex::encode(hasher.finalize()); let metrics_hash = hex::encode(hasher.finalize());
@ -329,7 +333,10 @@ fn announcement_sign_payload(
.into_bytes() .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 let metrics_hash = announcement
.verification_proof .verification_proof
.as_ref() .as_ref()

View File

@ -141,6 +141,73 @@ const App: Component = () => {
const [inviteCode, setInviteCode] = createSignal(""); const [inviteCode, setInviteCode] = createSignal("");
const [inviteLoading, setInviteLoading] = createSignal(false); const [inviteLoading, setInviteLoading] = createSignal(false);
const [inviteCopied, setInviteCopied] = 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 // react to community switches by loading channels, members, and selecting first channel
createEffect( createEffect(
@ -156,28 +223,7 @@ const App: Component = () => {
} }
if (tauriAvailable()) { if (tauriAvailable()) {
try { await hydrateCommunityState(communityId, false);
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);
}
} }
}), }),
); );
@ -406,9 +452,7 @@ const App: Component = () => {
// if the node itself has shut down (handled by stop_node command) // if the node itself has shut down (handled by stop_node command)
break; break;
case "sync_complete": case "sync_complete":
if (event.payload.community_id === activeCommunityId()) { void handleSyncComplete(event.payload.community_id);
reloadCurrentChannel();
}
break; break;
case "profile_received": case "profile_received":
// update our local directory cache when a peer announces their profile // update our local directory cache when a peer announces their profile
@ -477,14 +521,18 @@ const App: Component = () => {
} }
} }
async function reloadCurrentChannel() { async function handleSyncComplete(communityId: string) {
const channelId = activeChannelId(); if (!tauriAvailable()) return;
if (!channelId || !tauriAvailable()) return;
try { try {
const msgs = await tauri.getMessages(channelId); const allCommunities = await tauri.getCommunities();
setMessages(msgs); setCommunities(allCommunities);
} catch (e) { } 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 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 { import {
Search, Search,
X, X,
@ -13,76 +21,54 @@ import {
ChevronUp, ChevronUp,
Loader2, Loader2,
} from "lucide-solid"; } 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 { formatTime, formatDaySeparator } from "../../lib/utils";
import { extractMentions } from "../../lib/mentions";
import * as tauri from "../../lib/tauri"; import * as tauri from "../../lib/tauri";
// regex patterns for detecting media in message content const SEARCH_LIMIT = 300;
const IMAGE_REGEX = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)(\?[^\s]*)?$/i; const RESULT_ROW_HEIGHT = 56;
const VIDEO_REGEX = /\.(mp4|webm|mov|avi|mkv)(\?[^\s]*)?$/i; const RESULT_OVERSCAN = 6;
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";
interface DMSearchPanelProps { interface DMSearchPanelProps {
peerId: string; peerId: string;
myPeerId: string;
peerName: string; peerName: string;
onClose: () => void; onClose: () => void;
onJumpToMessage: (messageId: string, allMessages: DirectMessage[]) => void; onJumpToMessage: (messageId: string, timestamp: number) => void;
} }
const DMSearchPanel: Component<DMSearchPanelProps> = (props) => { const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
const [query, setQuery] = createSignal(""); const [query, setQuery] = createSignal("");
const [fromFilter, setFromFilter] = createSignal<FilterFrom>("anyone"); const [fromFilter, setFromFilter] = createSignal<DMSearchFrom>("anyone");
const [mediaFilter, setMediaFilter] = createSignal<MediaFilter | null>(null); const [mediaFilter, setMediaFilter] = createSignal<DMSearchMedia | null>(null);
const [mentionsOnly, setMentionsOnly] = createSignal(false); const [mentionsOnly, setMentionsOnly] = createSignal(false);
const [dateAfter, setDateAfter] = createSignal<string>(""); const [dateAfter, setDateAfter] = createSignal<string>("");
const [dateBefore, setDateBefore] = createSignal<string>(""); const [dateBefore, setDateBefore] = createSignal<string>("");
const [showFilters, setShowFilters] = createSignal(false); const [showFilters, setShowFilters] = createSignal(false);
const [results, setResults] = createSignal<DirectMessage[]>([]);
// full conversation loaded from disk for searching const [loading, setLoading] = createSignal(false);
const [allMessages, setAllMessages] = createSignal<DirectMessage[]>([]); const [resultScrollTop, setResultScrollTop] = createSignal(0);
const [loading, setLoading] = createSignal(true); const [resultViewportHeight, setResultViewportHeight] = createSignal(320);
let inputRef: HTMLInputElement | undefined; 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 // focus the search field when the panel opens
onMount(async () => { onMount(() => {
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(); inputRef?.focus();
}
}); });
// adapt DirectMessage[] to a searchable shape onCleanup(() => {
const searchableMessages = createMemo((): ChatMessage[] => if (searchDebounceTimer) {
allMessages().map((m) => ({ clearTimeout(searchDebounceTimer);
id: m.id, }
channel_id: `dm_${props.peerId}`, activeSearchId += 1;
author_id: m.from_peer, });
author_name: m.from_display_name,
content: m.content,
timestamp: m.timestamp,
edited: false,
})),
);
const hasActiveFilters = createMemo(() => { const hasActiveFilters = createMemo(() => {
return ( return (
@ -94,50 +80,97 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
); );
}); });
const filteredMessages = createMemo(() => { createEffect(() => {
const q = query().toLowerCase().trim(); const textQuery = query().trim();
const from = fromFilter(); const from = fromFilter();
const media = mediaFilter(); const media = mediaFilter();
const mentions = mentionsOnly(); const mentions = mentionsOnly();
const after = dateAfter(); const after = dateAfter();
const before = dateBefore(); const before = dateBefore();
const hasFilters = hasActiveFilters();
// no search or filters active, return nothing if (searchDebounceTimer) {
if (!q && !hasActiveFilters()) return []; clearTimeout(searchDebounceTimer);
searchDebounceTimer = undefined;
const afterTs = after ? new Date(after).getTime() : null;
const beforeTs = before
? new Date(before).getTime() + 86_400_000
: null;
return searchableMessages().filter((msg) => {
// text query
if (q && !msg.content.toLowerCase().includes(q)) return false;
// from filter
if (from === "me" && msg.author_id !== props.myPeerId) return false;
if (from === "them" && msg.author_id === props.myPeerId) return false;
// date range
if (afterTs && msg.timestamp < afterTs) return false;
if (beforeTs && msg.timestamp > beforeTs) return false;
// 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;
} }
// mentions only if (!textQuery && !hasFilters) {
if (mentions && extractMentions(msg.content).length === 0) return false; setLoading(false);
setResults([]);
return;
}
return true; const searchId = ++activeSearchId;
setLoading(true);
searchDebounceTimer = setTimeout(async () => {
const dateAfterTs = after ? new Date(after).getTime() : null;
const dateBeforeTs = before
? new Date(before).getTime() + 86_399_999
: null;
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,
}); });
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);
}
}
}, 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() { function clearAllFilters() {
setQuery(""); setQuery("");
setFromFilter("anyone"); setFromFilter("anyone");
@ -147,24 +180,20 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
setDateBefore(""); setDateBefore("");
} }
function handleJump(messageId: string) { function handleJump(message: DirectMessage) {
props.onJumpToMessage(messageId, allMessages()); props.onJumpToMessage(message.id, message.timestamp);
} }
// highlight matching text in a result snippet // highlight matching text in a result snippet
function highlightMatch(text: string): string { function highlightMatch(text: string): string {
const q = query().trim(); const textQuery = query().trim();
if (!q) return escapeHtml(truncate(text, 120)); if (!textQuery) return escapeHtml(truncate(text, 120));
const escaped = escapeHtml(truncate(text, 120)); const escaped = escapeHtml(truncate(text, 120));
const regex = new RegExp( const escapedQuery = escapeRegex(escapeHtml(textQuery));
`(${escapeRegex(escapeHtml(q))})`, const regex = new RegExp(`(${escapedQuery})`, "gi");
"gi",
); return escaped.replace(regex, '<span class="text-orange font-medium">$1</span>');
return escaped.replace(
regex,
'<span class="text-orange font-medium">$1</span>',
);
} }
return ( return (
@ -179,30 +208,33 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
> >
<Search size={16} class="shrink-0 text-white/40" /> <Search size={16} class="shrink-0 text-white/40" />
</Show> </Show>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder={loading() ? "loading messages..." : "search messages..."} placeholder={loading() ? "searching..." : "search messages..."}
value={query()} value={query()}
onInput={(e) => setQuery(e.currentTarget.value)} onInput={(event) => setQuery(event.currentTarget.value)}
disabled={loading()} class="flex-1 bg-transparent text-[14px] text-white placeholder:text-white/30 outline-none"
class="flex-1 bg-transparent text-[14px] text-white placeholder:text-white/30 outline-none disabled:opacity-50"
/> />
<Show when={!loading() && (query() || hasActiveFilters())}> <Show when={!loading() && (query() || hasActiveFilters())}>
<span class="text-[12px] font-mono text-white/40 shrink-0"> <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> </span>
</Show> </Show>
<button <button
type="button" type="button"
class="shrink-0 p-1 text-white/40 hover:text-white transition-colors duration-200 cursor-pointer" 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" aria-label="Toggle filters"
> >
<Show when={showFilters()} fallback={<ChevronDown size={16} />}> <Show when={showFilters()} fallback={<ChevronDown size={16} />}>
<ChevronUp size={16} /> <ChevronUp size={16} />
</Show> </Show>
</button> </button>
<button <button
type="button" type="button"
class="shrink-0 p-1 text-white/40 hover:text-white transition-colors duration-200 cursor-pointer" 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 <FilterChip
active={mediaFilter() === "images"} active={mediaFilter() === "images"}
onClick={() => onClick={() =>
setMediaFilter((v) => (v === "images" ? null : "images")) setMediaFilter((value) =>
value === "images" ? null : "images",
)
} }
icon={<Image size={12} />} icon={<Image size={12} />}
label="images" label="images"
@ -260,7 +294,9 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<FilterChip <FilterChip
active={mediaFilter() === "videos"} active={mediaFilter() === "videos"}
onClick={() => onClick={() =>
setMediaFilter((v) => (v === "videos" ? null : "videos")) setMediaFilter((value) =>
value === "videos" ? null : "videos",
)
} }
icon={<FileText size={12} />} icon={<FileText size={12} />}
label="videos" label="videos"
@ -268,7 +304,9 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<FilterChip <FilterChip
active={mediaFilter() === "links"} active={mediaFilter() === "links"}
onClick={() => onClick={() =>
setMediaFilter((v) => (v === "links" ? null : "links")) setMediaFilter((value) =>
value === "links" ? null : "links",
)
} }
icon={<Link size={12} />} icon={<Link size={12} />}
label="links" label="links"
@ -276,14 +314,16 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<FilterChip <FilterChip
active={mediaFilter() === "files"} active={mediaFilter() === "files"}
onClick={() => onClick={() =>
setMediaFilter((v) => (v === "files" ? null : "files")) setMediaFilter((value) =>
value === "files" ? null : "files",
)
} }
icon={<FileText size={12} />} icon={<FileText size={12} />}
label="files" label="files"
/> />
<FilterChip <FilterChip
active={mentionsOnly()} active={mentionsOnly()}
onClick={() => setMentionsOnly((v) => !v)} onClick={() => setMentionsOnly((value) => !value)}
icon={<AtSign size={12} />} icon={<AtSign size={12} />}
label="mentions" label="mentions"
/> />
@ -301,7 +341,7 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<input <input
type="date" type="date"
value={dateAfter()} 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]" 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" placeholder="after"
/> />
@ -311,7 +351,7 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
<input <input
type="date" type="date"
value={dateBefore()} 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]" 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" placeholder="before"
/> />
@ -320,7 +360,7 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
</div> </div>
{/* clear all */} {/* clear all */}
<Show when={hasActiveFilters()}> <Show when={hasActiveFilters() || query()}>
<button <button
type="button" type="button"
class="self-start text-[11px] font-mono text-orange hover:text-orange-hover transition-colors duration-200 cursor-pointer" 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> </Show>
{/* search results */} {/* search results */}
<Show when={!loading() && (query() || hasActiveFilters())}> <Show when={query() || hasActiveFilters()}>
<div class="max-h-[320px] overflow-y-auto border-t border-white/5"> <div
ref={resultsRef}
class="max-h-[320px] overflow-y-auto border-t border-white/5"
onScroll={handleResultScroll}
>
<Show <Show
when={filteredMessages().length > 0} when={!loading() && results().length > 0}
fallback={ fallback={
<div class="px-4 py-6 text-center text-[13px] text-white/30"> <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> </div>
} }
> >
<For each={filteredMessages()}> <div
{(msg) => ( class="relative"
style={{ height: `${Math.max(totalResultHeight(), 1)}px` }}
>
<For each={visibleResults()}>
{(row) => (
<button <button
type="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" 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"
onClick={() => handleJump(msg.id)} style={{
top: `${row.index * RESULT_ROW_HEIGHT}px`,
height: `${RESULT_ROW_HEIGHT}px`,
}}
onClick={() => handleJump(row.message)}
> >
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 pt-2">
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span class="text-[13px] font-medium text-white/80 group-hover:text-white truncate"> <span class="text-[13px] font-medium text-white/80 group-hover:text-white truncate">
{msg.author_name} {row.message.from_display_name}
</span> </span>
<span class="text-[11px] font-mono text-white/30"> <span class="text-[11px] font-mono text-white/30">
{formatDaySeparator(msg.timestamp)} {formatTime(msg.timestamp)} {formatDaySeparator(row.message.timestamp)}{" "}
{formatTime(row.message.timestamp)}
</span> </span>
</div> </div>
<p <p
class="text-[13px] text-white/50 truncate mt-0.5" class="text-[13px] text-white/50 truncate mt-0.5"
innerHTML={highlightMatch(msg.content)} innerHTML={highlightMatch(row.message.content)}
/> />
</div> </div>
</button> </button>
)} )}
</For> </For>
</div>
</Show> </Show>
</div> </div>
</Show> </Show>
@ -374,7 +430,6 @@ const DMSearchPanel: Component<DMSearchPanelProps> = (props) => {
); );
}; };
// reusable filter chip
interface FilterChipProps { interface FilterChipProps {
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
@ -397,13 +452,12 @@ const FilterChip: Component<FilterChipProps> = (props) => (
</button> </button>
); );
// utilities
function escapeHtml(str: string): string { function escapeHtml(str: string): string {
return str return str
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/\"/g, "&quot;");
} }
function escapeRegex(str: string): string { 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 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 { Phone, Pin, Search } from "lucide-solid";
import { import {
activeDMConversation, activeDMConversation,
dmMessages, dmMessages,
dmTypingPeers, dmTypingPeers,
prependDMMessages,
setDMMessages, setDMMessages,
} from "../../stores/dms"; } from "../../stores/dms";
import { onlinePeerIds } from "../../stores/members"; import { onlinePeerIds } from "../../stores/members";
import { identity } from "../../stores/identity"; import { identity } from "../../stores/identity";
import MessageList from "../chat/MessageList"; import VirtualMessageList from "../chat/VirtualMessageList";
import MessageInput from "../chat/MessageInput"; import MessageInput from "../chat/MessageInput";
import TypingIndicator from "../chat/TypingIndicator"; import TypingIndicator from "../chat/TypingIndicator";
import DMSearchPanel from "../chat/DMSearchPanel"; import DMSearchPanel from "../chat/DMSearchPanel";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import IconButton from "../common/IconButton"; 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 { interface DMChatAreaProps {
onSendDM: (content: string) => void; onSendDM: (content: string) => void;
onTyping: () => void; onTyping: () => void;
} }
const HISTORY_PAGE_SIZE = 80;
const JUMP_WINDOW_SIZE = 500;
const DMChatArea: Component<DMChatAreaProps> = (props) => { const DMChatArea: Component<DMChatAreaProps> = (props) => {
const [searchOpen, setSearchOpen] = createSignal(false); 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(); 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[] => const adaptedMessages = createMemo((): ChatMessage[] =>
dmMessages().map((m) => ({ dmMessages().map((message) => ({
id: m.id, id: message.id,
channel_id: `dm_${m.from_peer === dm()?.peer_id ? m.from_peer : m.to_peer}`, channel_id: `dm_${message.from_peer === dm()?.peer_id ? message.from_peer : message.to_peer}`,
author_id: m.from_peer, author_id: message.from_peer,
author_name: m.from_display_name, author_name: message.from_display_name,
content: m.content, content: message.content,
timestamp: m.timestamp, timestamp: message.timestamp,
edited: false, edited: false,
})), })),
); );
@ -47,29 +67,75 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
return "offline"; return "offline";
}); });
// scroll to a message by id, loading full history into the store if needed function focusMessage(messageId: string) {
function handleJumpToMessage( setFocusMessageId(null);
messageId: string, requestAnimationFrame(() => {
allMessages: DirectMessage[], setFocusMessageId(messageId);
) { });
const alreadyLoaded = dmMessages().some((m) => m.id === messageId);
if (!alreadyLoaded) {
// replace the store with the full history so the target is in the dom
setDMMessages(allMessages);
} }
// wait for the dom to update then scroll and highlight async function loadOlderMessages() {
requestAnimationFrame(() => { const peerId = dm()?.peer_id;
const el = document.querySelector( if (!peerId) return;
`[data-message-id="${messageId}"]`, if (loadingHistory() || !hasMoreHistory()) return;
) as HTMLElement | null;
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" }); const currentMessages = dmMessages();
el.classList.add("dusk-msg-search-highlight"); if (currentMessages.length === 0) return;
setTimeout(() => el.classList.remove("dusk-msg-search-highlight"), 2000);
}); 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;
}
try {
const aroundTarget = await tauri.getDMMessages(
peerId,
timestamp + 1,
JUMP_WINDOW_SIZE,
);
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 // typing indicator names
@ -78,7 +144,7 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
if (typing.length === 0) return []; if (typing.length === 0) return [];
const peer = dm(); const peer = dm();
if (!peer) return []; 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] : []; return typing.includes(peer.peer_id) ? [peer.display_name] : [];
}); });
@ -105,7 +171,7 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
<IconButton <IconButton
label="Search messages" label="Search messages"
active={searchOpen()} active={searchOpen()}
onClick={() => setSearchOpen((v) => !v)} onClick={() => setSearchOpen((value) => !value)}
> >
<Search size={18} /> <Search size={18} />
</IconButton> </IconButton>
@ -123,7 +189,6 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
<Show when={searchOpen() && dm()}> <Show when={searchOpen() && dm()}>
<DMSearchPanel <DMSearchPanel
peerId={dm()!.peer_id} peerId={dm()!.peer_id}
myPeerId={identity()?.peer_id ?? ""}
peerName={dm()!.display_name} peerName={dm()!.display_name}
onClose={() => setSearchOpen(false)} onClose={() => setSearchOpen(false)}
onJumpToMessage={handleJumpToMessage} onJumpToMessage={handleJumpToMessage}
@ -148,7 +213,12 @@ const DMChatArea: Component<DMChatAreaProps> = (props) => {
</div> </div>
} }
> >
<MessageList messages={adaptedMessages()} /> <VirtualMessageList
messages={adaptedMessages()}
conversationKey={dm()?.peer_id ?? ""}
focusMessageId={focusMessageId()}
onLoadMore={loadOlderMessages}
/>
</Show> </Show>
{/* typing indicator */} {/* typing indicator */}

View File

@ -6,14 +6,18 @@ import {
activeCommunityId, activeCommunityId,
setActiveCommunity, setActiveCommunity,
} from "../../stores/communities"; } from "../../stores/communities";
import { setActiveDM } from "../../stores/dms"; import { dmConversations, setActiveDM } from "../../stores/dms";
import { getInitials, hashColor } from "../../lib/utils"; import { getInitials, hashColor } from "../../lib/utils";
import { openModal } from "../../stores/ui"; import { openModal } from "../../stores/ui";
const ServerList: Component = () => { const ServerList: Component = () => {
const unreadDMCount = () =>
dmConversations().reduce((total, dm) => total + dm.unread_count, 0);
return ( 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"> <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 */} {/* home button */}
<div class="relative">
<button <button
type="button" type="button"
class={`w-12 h-12 flex items-center justify-center transition-all duration-200 cursor-pointer ${ class={`w-12 h-12 flex items-center justify-center transition-all duration-200 cursor-pointer ${
@ -28,6 +32,12 @@ const ServerList: Component = () => {
> >
<Home size={24} /> <Home size={24} />
</button> </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" /> <div class="w-8 border-t border-white/20 my-1" />

View File

@ -15,6 +15,7 @@ import type {
VoiceMediaState, VoiceMediaState,
DirectMessage, DirectMessage,
DMConversationMeta, DMConversationMeta,
DMSearchFilters,
GifResponse, GifResponse,
} from "./types"; } from "./types";
@ -374,6 +375,22 @@ export async function getDMMessages(
return invoke("get_dm_messages", { peerId, before, limit }); 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[]> { export async function getDMConversations(): Promise<DMConversationMeta[]> {
return invoke("get_dm_conversations"); return invoke("get_dm_conversations");
} }

View File

@ -125,6 +125,19 @@ export interface DMConversationMeta {
unread_count: number; 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 { export interface Member {
peer_id: string; peer_id: string;
display_name: string; display_name: string;

View File

@ -8,7 +8,16 @@ const [activeCommunityId, setActiveCommunityId] = createSignal<string | null>(
); );
export function addCommunity(community: CommunityMeta) { 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) { export function removeCommunity(id: string) {

View File

@ -61,7 +61,12 @@ export function clearDMUnread(peerId: string) {
} }
export function addDMMessage(message: DirectMessage) { 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() { export function clearDMMessages() {
@ -143,3 +148,16 @@ export {
setDMConversations, setDMConversations,
setDMMessages, 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;
}