2026-04-17 14:43:04 +08:00
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::fs;
|
2026-04-27 13:22:16 +02:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
use anyhow::Result;
|
2026-04-17 14:43:04 +08:00
|
|
|
use arboard::Clipboard;
|
2026-06-11 10:08:07 +02:00
|
|
|
use crossbeam_channel::Receiver;
|
2026-06-04 10:09:29 +08:00
|
|
|
use logos_chat::{ChatClient, DeliveryService, EphemeralRegistry, Event, RegistrationService};
|
2026-04-17 14:43:04 +08:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
use crate::utils::now;
|
2026-04-17 14:43:04 +08:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct DisplayMessage {
|
|
|
|
|
pub from_self: bool,
|
|
|
|
|
pub content: String,
|
|
|
|
|
pub timestamp: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct ChatSession {
|
|
|
|
|
pub chat_id: String,
|
2026-04-27 13:22:16 +02:00
|
|
|
pub nickname: Option<String>,
|
2026-04-17 14:43:04 +08:00
|
|
|
pub messages: Vec<DisplayMessage>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
impl ChatSession {
|
|
|
|
|
/// Human-readable label: nickname if set, otherwise the first 8 chars of the chat ID.
|
|
|
|
|
pub fn display_name(&self) -> &str {
|
|
|
|
|
self.nickname
|
|
|
|
|
.as_deref()
|
|
|
|
|
.unwrap_or_else(|| &self.chat_id[..8.min(self.chat_id.len())])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 14:43:04 +08:00
|
|
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
|
|
|
pub struct AppState {
|
2026-04-27 13:22:16 +02:00
|
|
|
/// Keyed by chat_id (conversation ID).
|
2026-04-17 14:43:04 +08:00
|
|
|
pub chats: HashMap<String, ChatSession>,
|
2026-04-27 13:22:16 +02:00
|
|
|
/// Holds the active chat_id.
|
2026-04-17 14:43:04 +08:00
|
|
|
pub active_chat: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
pub struct ChatApp<T: DeliveryService, R: RegistrationService = EphemeralRegistry> {
|
|
|
|
|
pub client: ChatClient<T, R>,
|
|
|
|
|
events: Receiver<Event>,
|
2026-04-17 14:43:04 +08:00
|
|
|
pub state: AppState,
|
2026-04-27 13:22:16 +02:00
|
|
|
/// Ephemeral command output — not persisted, cleared on chat switch.
|
|
|
|
|
command_output: Vec<DisplayMessage>,
|
2026-04-17 14:43:04 +08:00
|
|
|
pub input: String,
|
|
|
|
|
pub status: String,
|
|
|
|
|
pub user_name: String,
|
|
|
|
|
state_path: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
impl<T, R> ChatApp<T, R>
|
2026-06-04 10:09:29 +08:00
|
|
|
where
|
2026-06-11 10:08:07 +02:00
|
|
|
T: DeliveryService + Send + 'static,
|
|
|
|
|
R: RegistrationService + Send + 'static,
|
2026-06-04 10:09:29 +08:00
|
|
|
{
|
2026-04-27 13:22:16 +02:00
|
|
|
pub fn new(
|
2026-06-11 10:08:07 +02:00
|
|
|
client: ChatClient<T, R>,
|
|
|
|
|
events: Receiver<Event>,
|
2026-04-27 13:22:16 +02:00
|
|
|
user_name: &str,
|
|
|
|
|
data_dir: &Path,
|
|
|
|
|
) -> Result<Self> {
|
|
|
|
|
fs::create_dir_all(data_dir)?;
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
let state_path = data_dir.join(format!("{user_name}_state.json"));
|
2026-04-17 14:43:04 +08:00
|
|
|
let state = Self::load_state(&state_path);
|
|
|
|
|
|
|
|
|
|
let chat_count = state.chats.len();
|
|
|
|
|
let status = if chat_count > 0 {
|
|
|
|
|
format!(
|
2026-04-27 13:22:16 +02:00
|
|
|
"Welcome back, {user_name}! {chat_count} chat(s) loaded. Type /help for commands."
|
2026-04-17 14:43:04 +08:00
|
|
|
)
|
|
|
|
|
} else {
|
2026-04-27 13:22:16 +02:00
|
|
|
format!("Welcome, {user_name}! Type /help for commands.")
|
2026-04-17 14:43:04 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Self {
|
2026-04-27 13:22:16 +02:00
|
|
|
client,
|
2026-06-11 10:08:07 +02:00
|
|
|
events,
|
2026-04-17 14:43:04 +08:00
|
|
|
state,
|
2026-04-27 13:22:16 +02:00
|
|
|
command_output: Vec::new(),
|
2026-04-17 14:43:04 +08:00
|
|
|
input: String::new(),
|
|
|
|
|
status,
|
|
|
|
|
user_name: user_name.to_string(),
|
|
|
|
|
state_path,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
fn load_state(path: &Path) -> AppState {
|
2026-04-17 14:43:04 +08:00
|
|
|
if path.exists()
|
|
|
|
|
&& let Ok(contents) = fs::read_to_string(path)
|
|
|
|
|
&& let Ok(state) = serde_json::from_str(&contents)
|
|
|
|
|
{
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
AppState::default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn save_state(&self) -> Result<()> {
|
|
|
|
|
let json = serde_json::to_string_pretty(&self.state)?;
|
|
|
|
|
fs::write(&self.state_path, json)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn current_session(&self) -> Option<&ChatSession> {
|
|
|
|
|
self.state
|
|
|
|
|
.active_chat
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|name| self.state.chats.get(name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn messages(&self) -> Vec<&DisplayMessage> {
|
2026-04-27 13:22:16 +02:00
|
|
|
let chat = self
|
|
|
|
|
.current_session()
|
|
|
|
|
.map(|s| s.messages.as_slice())
|
|
|
|
|
.unwrap_or(&[]);
|
|
|
|
|
chat.iter().chain(self.command_output.iter()).collect()
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
fn set_active_chat(&mut self, chat_id: Option<String>) {
|
|
|
|
|
self.state.active_chat = chat_id;
|
|
|
|
|
self.command_output.clear();
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
/// Find a chat_id by nickname (exact) or chat_id prefix.
|
|
|
|
|
fn resolve_chat_id(&self, query: &str) -> Option<&str> {
|
|
|
|
|
// Exact nickname match first.
|
|
|
|
|
if let Some((id, _)) = self
|
|
|
|
|
.state
|
|
|
|
|
.chats
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|(_, s)| s.nickname.as_deref() == Some(query))
|
|
|
|
|
{
|
|
|
|
|
return Some(id.as_str());
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
2026-04-27 13:22:16 +02:00
|
|
|
// Fall back to chat_id prefix.
|
|
|
|
|
self.state
|
|
|
|
|
.chats
|
|
|
|
|
.keys()
|
|
|
|
|
.find(|id| id.starts_with(query))
|
|
|
|
|
.map(String::as_str)
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
pub fn process_incoming(&mut self) -> Result<()> {
|
2026-06-11 10:08:07 +02:00
|
|
|
let mut received = false;
|
|
|
|
|
while let Ok(event) = self.events.try_recv() {
|
|
|
|
|
self.handle_event(event);
|
|
|
|
|
received = true;
|
|
|
|
|
}
|
|
|
|
|
if received {
|
|
|
|
|
self.save_state()?;
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
2026-04-27 13:22:16 +02:00
|
|
|
Ok(())
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
fn handle_event(&mut self, event: Event) {
|
|
|
|
|
match event {
|
|
|
|
|
Event::ConversationStarted { convo_id, .. } => {
|
|
|
|
|
let chat_id = convo_id.to_string();
|
|
|
|
|
if self.state.chats.contains_key(&chat_id) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
self.state.chats.insert(
|
|
|
|
|
chat_id.clone(),
|
|
|
|
|
ChatSession {
|
|
|
|
|
chat_id: chat_id.clone(),
|
|
|
|
|
nickname: None,
|
|
|
|
|
messages: Vec::new(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
let label = &chat_id[..8.min(chat_id.len())];
|
|
|
|
|
self.status = format!("New chat ({label})! Use /nickname to name it.");
|
|
|
|
|
self.set_active_chat(Some(chat_id));
|
|
|
|
|
}
|
|
|
|
|
Event::MessageReceived {
|
|
|
|
|
convo_id, content, ..
|
|
|
|
|
} => {
|
|
|
|
|
let chat_id = convo_id.to_string();
|
|
|
|
|
let Some(session) = self.state.chats.get_mut(&chat_id) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
session.messages.push(DisplayMessage {
|
|
|
|
|
from_self: false,
|
|
|
|
|
content: String::from_utf8_lossy(&content).into_owned(),
|
|
|
|
|
timestamp: now(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-11 10:08:07 +02:00
|
|
|
Event::InboundError { message } => {
|
|
|
|
|
self.status = format!("Could not process incoming message: {message}");
|
|
|
|
|
}
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 14:43:04 +08:00
|
|
|
pub fn send_message(&mut self, content: &str) -> Result<()> {
|
2026-04-27 13:22:16 +02:00
|
|
|
let chat_id = self
|
2026-04-17 14:43:04 +08:00
|
|
|
.state
|
|
|
|
|
.active_chat
|
|
|
|
|
.clone()
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?;
|
|
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
self.client
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
.send_message(&chat_id, content.as_bytes())
|
2026-04-27 13:22:16 +02:00
|
|
|
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
if let Some(session) = self.state.chats.get_mut(&chat_id) {
|
2026-04-17 14:43:04 +08:00
|
|
|
session.messages.push(DisplayMessage {
|
|
|
|
|
from_self: true,
|
|
|
|
|
content: content.to_string(),
|
|
|
|
|
timestamp: now(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
self.save_state()?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn add_system_message(&mut self, content: &str) {
|
2026-04-27 13:22:16 +02:00
|
|
|
self.command_output.push(DisplayMessage {
|
2026-04-17 14:43:04 +08:00
|
|
|
from_self: true,
|
|
|
|
|
content: content.to_string(),
|
|
|
|
|
timestamp: now(),
|
2026-04-27 13:22:16 +02:00
|
|
|
});
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn handle_command(&mut self, cmd: &str) -> Result<Option<String>> {
|
|
|
|
|
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
|
|
|
|
let command = parts[0];
|
|
|
|
|
let args = parts.get(1).copied().unwrap_or("");
|
|
|
|
|
|
|
|
|
|
match command {
|
|
|
|
|
"/help" => {
|
|
|
|
|
self.add_system_message("── Commands ──");
|
|
|
|
|
self.add_system_message("/intro - Show your introduction bundle");
|
2026-04-27 13:22:16 +02:00
|
|
|
self.add_system_message("/connect <bundle> - Connect using a bundle");
|
|
|
|
|
self.add_system_message("/nickname <name> - Name the active chat");
|
2026-04-17 14:43:04 +08:00
|
|
|
self.add_system_message("/chats - List all chats");
|
2026-04-27 13:22:16 +02:00
|
|
|
self.add_system_message("/switch <name|id> - Switch active chat");
|
|
|
|
|
self.add_system_message("/delete <name|id> - Delete a chat");
|
2026-04-17 14:43:04 +08:00
|
|
|
self.add_system_message("/status - Show connection status");
|
|
|
|
|
self.add_system_message("/clear - Clear current chat messages");
|
|
|
|
|
self.add_system_message("/quit or Esc or Ctrl+C - Exit");
|
|
|
|
|
Ok(Some("Help displayed".to_string()))
|
|
|
|
|
}
|
|
|
|
|
"/intro" => {
|
2026-04-27 13:22:16 +02:00
|
|
|
let bundle_bytes = self
|
|
|
|
|
.client
|
|
|
|
|
.create_intro_bundle()
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
|
|
|
|
let bundle_str = String::from_utf8_lossy(&bundle_bytes).to_string();
|
2026-04-17 14:43:04 +08:00
|
|
|
self.add_system_message("── Your Introduction Bundle ──");
|
2026-04-27 13:22:16 +02:00
|
|
|
self.add_system_message(&bundle_str);
|
|
|
|
|
let clipboard_msg = match Clipboard::new()
|
|
|
|
|
.and_then(|mut cb| cb.set_text(&bundle_str))
|
|
|
|
|
{
|
|
|
|
|
Ok(()) => "Bundle copied to clipboard! Share it, then /connect their bundle.",
|
2026-04-17 14:43:04 +08:00
|
|
|
Err(_) => "Share this bundle with others to connect!",
|
|
|
|
|
};
|
|
|
|
|
self.add_system_message(clipboard_msg);
|
2026-04-27 13:22:16 +02:00
|
|
|
Ok(Some("Bundle created".to_string()))
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
"/connect" => {
|
2026-04-27 13:22:16 +02:00
|
|
|
if args.is_empty() {
|
|
|
|
|
return Ok(Some("Usage: /connect <bundle>".to_string()));
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
2026-04-27 13:22:16 +02:00
|
|
|
let initial = format!("Hello from {}!", self.user_name);
|
feat: introduce client event system (#106)
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access
2026-05-28 23:51:15 +02:00
|
|
|
let chat_id = self
|
2026-04-27 13:22:16 +02:00
|
|
|
.client
|
|
|
|
|
.create_conversation(args.as_bytes(), initial.as_bytes())
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
|
|
|
|
|
|
|
|
|
let label = chat_id[..8.min(chat_id.len())].to_string();
|
|
|
|
|
let mut session = ChatSession {
|
|
|
|
|
chat_id: chat_id.clone(),
|
|
|
|
|
nickname: None,
|
|
|
|
|
messages: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
session.messages.push(DisplayMessage {
|
|
|
|
|
from_self: true,
|
|
|
|
|
content: initial,
|
|
|
|
|
timestamp: now(),
|
|
|
|
|
});
|
|
|
|
|
self.state.chats.insert(chat_id.clone(), session);
|
|
|
|
|
self.set_active_chat(Some(chat_id));
|
|
|
|
|
self.save_state()?;
|
|
|
|
|
self.status = format!("Connected ({label})! Use /nickname to name this chat.");
|
|
|
|
|
Ok(Some(format!("Connected ({label})")))
|
|
|
|
|
}
|
|
|
|
|
"/nickname" => {
|
|
|
|
|
if args.is_empty() {
|
|
|
|
|
return Ok(Some("Usage: /nickname <name>".to_string()));
|
|
|
|
|
}
|
|
|
|
|
let chat_id = self
|
|
|
|
|
.state
|
|
|
|
|
.active_chat
|
|
|
|
|
.clone()
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("No active chat."))?;
|
|
|
|
|
let session = self
|
|
|
|
|
.state
|
|
|
|
|
.chats
|
|
|
|
|
.get_mut(&chat_id)
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("Chat session not found."))?;
|
|
|
|
|
session.nickname = Some(args.to_string());
|
|
|
|
|
self.save_state()?;
|
|
|
|
|
self.status = format!("Chat named '{args}'.");
|
|
|
|
|
Ok(Some(format!("Nickname set to '{args}'")))
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
"/chats" => {
|
2026-04-27 13:22:16 +02:00
|
|
|
let sessions: Vec<_> = self.state.chats.values().cloned().collect();
|
|
|
|
|
if sessions.is_empty() {
|
2026-04-17 14:43:04 +08:00
|
|
|
Ok(Some("No chats yet. Use /connect to start one.".to_string()))
|
|
|
|
|
} else {
|
2026-04-27 13:22:16 +02:00
|
|
|
self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len()));
|
|
|
|
|
for s in &sessions {
|
|
|
|
|
let marker = if self.state.active_chat.as_deref() == Some(&s.chat_id) {
|
2026-04-17 14:43:04 +08:00
|
|
|
" (active)"
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
};
|
2026-04-27 13:22:16 +02:00
|
|
|
let label = format!(
|
|
|
|
|
" • {} ({}){marker}",
|
|
|
|
|
s.display_name(),
|
|
|
|
|
&s.chat_id[..8.min(s.chat_id.len())]
|
|
|
|
|
);
|
|
|
|
|
self.add_system_message(&label);
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
2026-04-27 13:22:16 +02:00
|
|
|
Ok(Some(format!("{} chat(s)", sessions.len())))
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"/switch" => {
|
|
|
|
|
if args.is_empty() {
|
2026-04-27 13:22:16 +02:00
|
|
|
return Ok(Some("Usage: /switch <nickname|id-prefix>".to_string()));
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
2026-04-27 13:22:16 +02:00
|
|
|
let chat_id = self
|
|
|
|
|
.resolve_chat_id(args)
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("No chat matching '{args}'."))?;
|
|
|
|
|
let label = self.state.chats[&chat_id].display_name().to_string();
|
|
|
|
|
self.set_active_chat(Some(chat_id));
|
|
|
|
|
self.save_state()?;
|
|
|
|
|
self.status = format!("Switched to '{label}'.");
|
|
|
|
|
Ok(Some(format!("Switched to '{label}'")))
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
"/delete" => {
|
|
|
|
|
if args.is_empty() {
|
2026-04-27 13:22:16 +02:00
|
|
|
return Ok(Some("Usage: /delete <nickname|id-prefix>".to_string()));
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
2026-04-27 13:22:16 +02:00
|
|
|
let chat_id = self
|
|
|
|
|
.resolve_chat_id(args)
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("No chat matching '{args}'."))?;
|
|
|
|
|
let label = self.state.chats[&chat_id].display_name().to_string();
|
|
|
|
|
self.state.chats.remove(&chat_id);
|
|
|
|
|
if self.state.active_chat.as_deref() == Some(&chat_id) {
|
|
|
|
|
self.state.active_chat = self.state.chats.keys().next().cloned();
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
2026-04-27 13:22:16 +02:00
|
|
|
self.save_state()?;
|
|
|
|
|
self.status = format!("Deleted '{label}'.");
|
|
|
|
|
Ok(Some(format!("Deleted '{label}'")))
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
"/status" => {
|
2026-04-27 13:22:16 +02:00
|
|
|
let active_label = self
|
|
|
|
|
.state
|
|
|
|
|
.active_chat
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|id| self.state.chats.get(id))
|
|
|
|
|
.map(|s| {
|
|
|
|
|
format!(
|
|
|
|
|
"{} ({})",
|
|
|
|
|
s.display_name(),
|
|
|
|
|
&s.chat_id[..8.min(s.chat_id.len())]
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_else(|| "none".to_string());
|
2026-04-17 14:43:04 +08:00
|
|
|
let status = format!(
|
2026-04-27 13:22:16 +02:00
|
|
|
"User: {}\nIdentity: {}\nChats: {}\nActive: {}",
|
2026-04-17 14:43:04 +08:00
|
|
|
self.user_name,
|
2026-04-27 13:22:16 +02:00
|
|
|
self.client.installation_name(),
|
|
|
|
|
self.state.chats.len(),
|
|
|
|
|
active_label,
|
2026-04-17 14:43:04 +08:00
|
|
|
);
|
|
|
|
|
Ok(Some(status))
|
|
|
|
|
}
|
|
|
|
|
"/clear" => {
|
|
|
|
|
if let Some(active) = &self.state.active_chat.clone()
|
|
|
|
|
&& let Some(session) = self.state.chats.get_mut(active)
|
|
|
|
|
{
|
|
|
|
|
session.messages.clear();
|
|
|
|
|
self.save_state()?;
|
|
|
|
|
}
|
|
|
|
|
Ok(Some("Messages cleared".to_string()))
|
|
|
|
|
}
|
|
|
|
|
"/quit" => Ok(None),
|
|
|
|
|
_ => Ok(Some(format!(
|
2026-04-27 13:22:16 +02:00
|
|
|
"Unknown command: {command}. Type /help for commands."
|
2026-04-17 14:43:04 +08:00
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|