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:
2026-04-04 23:31:37 +02:00
parent 4dadd01c6b
commit f58ce2529d
14 changed files with 662 additions and 127 deletions

View File

@@ -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();

View File

@@ -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`.

View File

@@ -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();