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};
|
|
|
|
|
use std::sync::mpsc;
|
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-05-20 13:18:25 -07:00
|
|
|
use logos_chat::{ChatClient, ConversationIdOwned, DeliveryService};
|
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-04-27 13:22:16 +02:00
|
|
|
pub struct ChatApp<D: DeliveryService> {
|
2026-05-20 13:18:25 -07:00
|
|
|
pub client: ChatClient<D>,
|
2026-04-27 13:22:16 +02:00
|
|
|
inbound: mpsc::Receiver<Vec<u8>>,
|
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-05-19 11:54:54 -07:00
|
|
|
impl<D: DeliveryService + 'static> ChatApp<D> {
|
2026-04-27 13:22:16 +02:00
|
|
|
pub fn new(
|
2026-05-20 13:18:25 -07:00
|
|
|
client: ChatClient<D>,
|
2026-04-27 13:22:16 +02:00
|
|
|
inbound: mpsc::Receiver<Vec<u8>>,
|
|
|
|
|
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,
|
|
|
|
|
inbound,
|
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<()> {
|
|
|
|
|
while let Ok(payload) = self.inbound.try_recv() {
|
|
|
|
|
match self.client.receive(&payload) {
|
|
|
|
|
Ok(Some(content)) => {
|
|
|
|
|
let chat_id = &content.conversation_id;
|
|
|
|
|
|
|
|
|
|
if !self.state.chats.contains_key(chat_id) && content.is_new_convo {
|
|
|
|
|
let session = ChatSession {
|
|
|
|
|
chat_id: chat_id.clone(),
|
|
|
|
|
nickname: None,
|
|
|
|
|
messages: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
self.state.chats.insert(chat_id.clone(), session);
|
|
|
|
|
let label = chat_id[..8.min(chat_id.len())].to_string();
|
|
|
|
|
self.set_active_chat(Some(chat_id.clone()));
|
|
|
|
|
self.status = format!("New chat ({label})! Use /nickname to name it.");
|
|
|
|
|
}
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
if !content.data.is_empty() {
|
|
|
|
|
let text = String::from_utf8_lossy(&content.data).to_string();
|
|
|
|
|
if let Some(session) = self.state.chats.get_mut(chat_id) {
|
|
|
|
|
session.messages.push(DisplayMessage {
|
|
|
|
|
from_self: false,
|
|
|
|
|
content: text,
|
|
|
|
|
timestamp: now(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
self.save_state()?;
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => {}
|
|
|
|
|
Err(e) => tracing::warn!("receive error: {e:?}"),
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
let convo_id: ConversationIdOwned = chat_id.as_str().into();
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
self.client
|
|
|
|
|
.send_message(&convo_id, content.as_bytes())
|
|
|
|
|
.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);
|
|
|
|
|
let convo_id = self
|
|
|
|
|
.client
|
|
|
|
|
.create_conversation(args.as_bytes(), initial.as_bytes())
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
|
|
|
|
|
|
|
|
|
let chat_id = convo_id.to_string();
|
|
|
|
|
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
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|