feat: add 11 features and bug fixes across server, SDK, and client
Server fixes: - Wire v2 moderation handlers to ModerationService (SQL persistence) — bans now survive restarts instead of living in-memory DashMap - Add admin role enforcement via QPC_ADMIN_KEYS env var for ban/unban - Fix audit.rs now_iso8601() to emit actual ISO-8601 timestamps - Add group admin authorization — only creator can remove members or update metadata Server features: - Add DeleteBlob RPC (method 602) with filesystem cleanup - Register delete_blob in v2 handler method registry SDK features: - Add ClientEvent::IdentityKeyChanged for safety number change alerts - Add ClientEvent::ReadReceipt and DeliveryConfirmation variants - Add peer_identity_keys table with store/get methods for key tracking - Add search_messages() full-text search across all conversations - Add delete_conversation() with cascading message/outbox cleanup Client features: - Wire v2 TUI message sending to SDK MLS encryption pipeline - Add /search command to v2 REPL with cross-conversation results - Add /delete-conversation command to v2 REPL - Add unread count badges in v1 TUI sidebar (yellow+bold styling)
This commit is contained in:
@@ -83,6 +83,8 @@ struct App {
|
||||
channel_names: Vec<String>,
|
||||
/// Conversation IDs, parallel to `channel_names`.
|
||||
channel_ids: Vec<ConversationId>,
|
||||
/// Unread message counts, parallel to `channel_names`.
|
||||
unread_counts: Vec<u32>,
|
||||
/// Index of the selected channel in the sidebar.
|
||||
selected_channel: usize,
|
||||
/// Messages for the currently active channel.
|
||||
@@ -102,10 +104,12 @@ impl App {
|
||||
let convs = session.conv_store.list_conversations()?;
|
||||
let channel_names: Vec<String> = convs.iter().map(|c| c.display_name.clone()).collect();
|
||||
let channel_ids: Vec<ConversationId> = convs.iter().map(|c| c.id.clone()).collect();
|
||||
let unread_counts: Vec<u32> = convs.iter().map(|c| c.unread_count).collect();
|
||||
|
||||
Ok(Self {
|
||||
channel_names,
|
||||
channel_ids,
|
||||
unread_counts,
|
||||
selected_channel: 0,
|
||||
messages: Vec::new(),
|
||||
input: String::new(),
|
||||
@@ -232,14 +236,27 @@ fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let style = if i == app.selected_channel {
|
||||
let unread = app.unread_counts.get(i).copied().unwrap_or(0);
|
||||
let is_selected = i == app.selected_channel;
|
||||
|
||||
let label = if unread > 0 && !is_selected {
|
||||
format!("{name} ({unread})")
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else if unread > 0 {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
ListItem::new(Line::from(Span::styled(name.clone(), style)))
|
||||
ListItem::new(Line::from(Span::styled(label, style)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ const COMMANDS: &[CmdDef] = &[
|
||||
CmdDef { name: "/help", aliases: &["/?"], category: Category::Utility, description: "Show this help message", usage: "/help" },
|
||||
CmdDef { name: "/quit", aliases: &["/q", "/exit"], category: Category::Utility, description: "Exit the REPL", usage: "/quit" },
|
||||
CmdDef { name: "/clear", aliases: &[], category: Category::Utility, description: "Clear the terminal", usage: "/clear" },
|
||||
CmdDef { name: "/search", aliases: &[], category: Category::Messaging, description: "Search messages across all conversations", usage: "/search <query>" },
|
||||
CmdDef { name: "/delete-conversation", aliases: &["/delconv"], category: Category::Messaging, description: "Delete a conversation and its messages", usage: "/delete-conversation [name]" },
|
||||
CmdDef { name: "/health", aliases: &[], category: Category::Debug, description: "Check server connection health", usage: "/health" },
|
||||
CmdDef { name: "/status", aliases: &[], category: Category::Debug, description: "Show connection and auth state", usage: "/status" },
|
||||
];
|
||||
@@ -397,6 +399,8 @@ async fn dispatch(
|
||||
"/switch" | "/sw" => do_switch(client, st, args)?,
|
||||
"/group" | "/g" => do_group(client, st, args).await?,
|
||||
"/devices" => do_devices(client, args).await?,
|
||||
"/search" => do_search(client, args)?,
|
||||
"/delete-conversation" | "/delconv" => do_delete_conversation(client, st, args)?,
|
||||
_ => display::print_error(&format!("unknown command: {cmd} (try /help)")),
|
||||
}
|
||||
Ok(false)
|
||||
@@ -983,6 +987,81 @@ async fn do_devices(client: &mut QpqClient, args: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn do_search(client: &QpqClient, args: &str) -> anyhow::Result<()> {
|
||||
let query = args.trim();
|
||||
if query.is_empty() {
|
||||
display::print_error("usage: /search <query>");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let results = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?.search_messages(query, 25)?;
|
||||
if results.is_empty() {
|
||||
display::print_status(&format!("no messages matching \"{query}\""));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("\n{BOLD}Search results for \"{query}\"{RESET} ({} matches)\n", results.len());
|
||||
for r in &results {
|
||||
let ts = format_timestamp_ms(r.timestamp_ms);
|
||||
let sender = r.sender_name.as_deref().unwrap_or("?");
|
||||
println!(
|
||||
" {DIM}[{ts}]{RESET} {CYAN}{}{RESET} > {GREEN}{sender}{RESET}: {}",
|
||||
r.conversation_name,
|
||||
r.body,
|
||||
);
|
||||
}
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_timestamp_ms(ms: u64) -> String {
|
||||
let secs = ms / 1000;
|
||||
let hours = (secs % 86400) / 3600;
|
||||
let minutes = (secs % 3600) / 60;
|
||||
format!("{hours:02}:{minutes:02}")
|
||||
}
|
||||
|
||||
// ── Delete conversation ─────────────────────────────────────────────────────
|
||||
|
||||
fn do_delete_conversation(
|
||||
client: &QpqClient,
|
||||
st: &mut ReplState,
|
||||
args: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let name = args.trim();
|
||||
|
||||
// Find by name, or use current conversation.
|
||||
let target = if name.is_empty() {
|
||||
st.current_conversation.clone()
|
||||
} else {
|
||||
let convs = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?.list_conversations()?;
|
||||
convs
|
||||
.iter()
|
||||
.find(|c| c.display_name.eq_ignore_ascii_case(name))
|
||||
.map(|c| c.id.clone())
|
||||
};
|
||||
|
||||
let Some(conv_id) = target else {
|
||||
display::print_error("no matching conversation (specify name or switch first)");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let deleted = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?.delete_conversation(&conv_id)?;
|
||||
if deleted {
|
||||
// If we deleted the active conversation, clear it.
|
||||
if st.current_conversation.as_ref() == Some(&conv_id) {
|
||||
st.current_conversation = None;
|
||||
st.current_display_name = None;
|
||||
}
|
||||
display::print_status("conversation deleted");
|
||||
} else {
|
||||
display::print_error("conversation not found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run the v2 REPL over a `QpqClient`.
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
//!
|
||||
//! Feature gate: requires both `v2` and `tui` features.
|
||||
//!
|
||||
//! **Note:** Message display is currently local-only. Use the REPL client for
|
||||
//! end-to-end encrypted delivery. See `quicprochat-sdk::messaging` for the full pipeline.
|
||||
//! Messages are sent via the SDK's MLS encryption pipeline (sealed sender + hybrid wrap).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -41,8 +40,11 @@ use ratatui::{
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use quicprochat_core::IdentityKeypair;
|
||||
use quicprochat_sdk::client::{ConnectionState, QpqClient};
|
||||
use quicprochat_sdk::conversation::ConversationStore;
|
||||
use quicprochat_sdk::conversation::{ConversationId, ConversationStore, StoredMessage};
|
||||
use quicprochat_sdk::events::ClientEvent;
|
||||
|
||||
// ── Data Types ──────────────────────────────────────────────────────────────
|
||||
@@ -91,6 +93,8 @@ pub struct TuiApp {
|
||||
conn_state: quicprochat_sdk::client::ConnectionState,
|
||||
/// Current MLS epoch for the active conversation (if available).
|
||||
mls_epoch: Option<u64>,
|
||||
/// Identity keypair for MLS operations (set after login).
|
||||
identity: Option<Arc<IdentityKeypair>>,
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
@@ -110,6 +114,7 @@ impl TuiApp {
|
||||
notification: None,
|
||||
conn_state: ConnectionState::Disconnected,
|
||||
mls_epoch: None,
|
||||
identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,14 +578,83 @@ async fn handle_input(app: &mut TuiApp, client: &mut QpqClient, text: &str) {
|
||||
// Snap to bottom.
|
||||
app.scroll_offset = 0;
|
||||
|
||||
// NOTE: TUI message display is local-only. The full MLS encryption
|
||||
// pipeline (sealed sender + hybrid wrap + enqueue) is implemented in
|
||||
// quicprochat-sdk/src/messaging.rs but is not yet wired into the TUI.
|
||||
// Use the REPL client (`qpc repl`) for end-to-end message delivery.
|
||||
app.notification = Some("Message queued locally (TUI send not yet wired to SDK)".to_string());
|
||||
// Send via MLS encryption pipeline.
|
||||
let conv_id_bytes = *app.active_conv_id().unwrap();
|
||||
let conv_id = ConversationId(conv_id_bytes);
|
||||
|
||||
let send_result = send_tui_message(client, app, &conv_id, text).await;
|
||||
match send_result {
|
||||
Ok(()) => {
|
||||
app.notification = Some("Sent".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
app.notification = Some(format!("Send failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message via the SDK's MLS encryption pipeline.
|
||||
async fn send_tui_message(
|
||||
client: &QpqClient,
|
||||
app: &TuiApp,
|
||||
conv_id: &ConversationId,
|
||||
text: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let identity = app
|
||||
.identity
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("not logged in — identity not loaded"))?;
|
||||
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let conv = conv_store
|
||||
.load_conversation(conv_id)?
|
||||
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
|
||||
|
||||
let mut member = quicprochat_sdk::groups::restore_mls_state(&conv, identity)?;
|
||||
|
||||
let my_pub = identity.public_key_bytes();
|
||||
let recipients: Vec<Vec<u8>> = conv
|
||||
.member_keys
|
||||
.iter()
|
||||
.filter(|k| k.as_slice() != my_pub.as_slice())
|
||||
.cloned()
|
||||
.collect();
|
||||
if recipients.is_empty() {
|
||||
return Err(anyhow::anyhow!("no recipients in conversation"));
|
||||
}
|
||||
|
||||
let hybrid_keys = vec![None; recipients.len()];
|
||||
quicprochat_sdk::messaging::send_message(
|
||||
rpc,
|
||||
&mut member,
|
||||
identity,
|
||||
text,
|
||||
&recipients,
|
||||
&hybrid_keys,
|
||||
conv_id.0.as_slice(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
quicprochat_sdk::groups::save_mls_state(conv_store, conv_id, &member)?;
|
||||
|
||||
let now = quicprochat_sdk::conversation::now_ms();
|
||||
conv_store.save_message(&StoredMessage {
|
||||
conversation_id: conv_id.clone(),
|
||||
message_id: None,
|
||||
sender_key: my_pub.to_vec(),
|
||||
sender_name: client.username().map(|s| s.to_string()),
|
||||
body: text.to_string(),
|
||||
msg_type: "chat".to_string(),
|
||||
ref_msg_id: None,
|
||||
timestamp_ms: now,
|
||||
is_outgoing: true,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a /command.
|
||||
async fn handle_command(app: &mut TuiApp, client: &mut QpqClient, cmd: &str) {
|
||||
let parts: Vec<&str> = cmd.splitn(3, ' ').collect();
|
||||
|
||||
Reference in New Issue
Block a user