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:
parent
a4e7e51f50
commit
11a987e0de
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ hex = "0.4"
|
|||
|
||||
# data storage
|
||||
directories = "5"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
# env file support
|
||||
dotenvy = "0.15"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,11 +254,36 @@ 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
|
||||
|
|
@ -228,7 +299,6 @@ pub async fn leave_community(
|
|||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let presence_topic = gossip::topic_for_presence(&community_id);
|
||||
let _ = handle
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶ms)
|
||||
.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(
|
||||
|
|
|
|||
|
|
@ -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))?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,10 +541,36 @@ 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
|
||||
|
|
@ -499,7 +586,6 @@ async fn leave_community(
|
|||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let presence_topic = gossip::topic_for_presence(&community_id);
|
||||
let _ = handle
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,4 +1,5 @@
|
|||
mod disk;
|
||||
|
||||
pub use disk::DiskStorage;
|
||||
pub use disk::DmSearchParams;
|
||||
pub use disk::UserSettings;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
110
src/App.tsx
110
src/App.tsx
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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 [];
|
||||
|
||||
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;
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = undefined;
|
||||
}
|
||||
|
||||
// mentions only
|
||||
if (mentions && extractMentions(msg.content).length === 0) return false;
|
||||
if (!textQuery && !hasFilters) {
|
||||
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() {
|
||||
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) => (
|
||||
<div
|
||||
class="relative"
|
||||
style={{ height: `${Math.max(totalResultHeight(), 1)}px` }}
|
||||
>
|
||||
<For each={visibleResults()}>
|
||||
{(row) => (
|
||||
<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)}
|
||||
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">
|
||||
<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">
|
||||
{msg.author_name}
|
||||
{row.message.from_display_name}
|
||||
</span>
|
||||
<span class="text-[11px] font-mono text-white/30">
|
||||
{formatDaySeparator(msg.timestamp)} {formatTime(msg.timestamp)}
|
||||
{formatDaySeparator(row.message.timestamp)}{" "}
|
||||
{formatTime(row.message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-[13px] text-white/50 truncate mt-0.5"
|
||||
innerHTML={highlightMatch(msg.content)}
|
||||
innerHTML={highlightMatch(row.message.content)}
|
||||
/>
|
||||
</div>
|
||||
</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/\"/g, """);
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
||||
if (!alreadyLoaded) {
|
||||
// replace the store with the full history so the target is in the dom
|
||||
setDMMessages(allMessages);
|
||||
function focusMessage(messageId: string) {
|
||||
setFocusMessageId(null);
|
||||
requestAnimationFrame(() => {
|
||||
setFocusMessageId(messageId);
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
async function loadOlderMessages() {
|
||||
const peerId = dm()?.peer_id;
|
||||
if (!peerId) return;
|
||||
if (loadingHistory() || !hasMoreHistory()) return;
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("dusk-msg-search-highlight");
|
||||
setTimeout(() => el.classList.remove("dusk-msg-search-highlight"), 2000);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,18 @@ 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 */}
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
</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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue