scroll-chat 0.1.0

A secure terminal chat over SSH - host or join chatrooms with end-to-end encryption
//! Chat room state management

use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};

/// Maximum messages to keep in history
const MAX_HISTORY: usize = 1000;

/// Broadcast channel capacity
const BROADCAST_CAPACITY: usize = 256;

/// A chat message
#[derive(Clone, Debug)]
pub struct Message {
    pub id: uuid::Uuid,
    pub timestamp: DateTime<Utc>,
    pub username: String,
    pub content: MessageContent,
}

/// Types of message content
#[derive(Clone, Debug)]
pub enum MessageContent {
    /// Regular text message
    Text(String),
    /// System message (join/leave/etc)
    System(String),
}

impl Message {
    /// Create a new text message
    pub fn text(username: impl Into<String>, content: impl Into<String>) -> Self {
        Self {
            id: uuid::Uuid::new_v4(),
            timestamp: Utc::now(),
            username: username.into(),
            content: MessageContent::Text(content.into()),
        }
    }

    /// Create a system message
    pub fn system(content: impl Into<String>) -> Self {
        Self {
            id: uuid::Uuid::new_v4(),
            timestamp: Utc::now(),
            username: String::new(),
            content: MessageContent::System(content.into()),
        }
    }
}

/// Events broadcast to all connected clients
#[derive(Clone, Debug)]
pub enum RoomEvent {
    /// New message received
    NewMessage(Message),
    /// User joined the room
    UserJoined(String),
    /// User left the room
    UserLeft(String),
    /// Request to refresh the UI
    Refresh,
}

/// Information about a connected user
#[derive(Clone, Debug)]
pub struct UserSession {
    pub username: String,
    pub joined_at: DateTime<Utc>,
    pub terminal_size: (u16, u16), // (cols, rows)
}

impl UserSession {
    pub fn new(username: impl Into<String>, cols: u16, rows: u16) -> Self {
        Self {
            username: username.into(),
            joined_at: Utc::now(),
            terminal_size: (cols, rows),
        }
    }
}

/// The chat room state
pub struct ChatRoom {
    /// Room name/title
    pub name: String,
    /// Password hash (Argon2)
    password_hash: String,
    /// Message history
    messages: RwLock<Vec<Message>>,
    /// Connected users by session ID
    users: RwLock<HashMap<u64, UserSession>>,
    /// Broadcast channel for real-time events
    broadcast_tx: broadcast::Sender<RoomEvent>,
    /// Maximum allowed connections
    max_connections: usize,
    /// Next session ID
    next_session_id: RwLock<u64>,
    /// Tunnel URL (if using cloudflared)
    pub tunnel_url: RwLock<Option<String>>,
}

impl ChatRoom {
    /// Create a new chat room
    pub fn new(name: impl Into<String>, password_hash: String) -> Arc<Self> {
        let (broadcast_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
        Arc::new(Self {
            name: name.into(),
            password_hash,
            messages: RwLock::new(Vec::with_capacity(MAX_HISTORY)),
            users: RwLock::new(HashMap::new()),
            broadcast_tx,
            max_connections: 50,
            next_session_id: RwLock::new(1),
            tunnel_url: RwLock::new(None),
        })
    }

    /// Verify the room password
    pub fn verify_password(&self, password: &str) -> bool {
        use argon2::{Argon2, PasswordHash, PasswordVerifier};
        
        match PasswordHash::new(&self.password_hash) {
            Ok(parsed_hash) => Argon2::default()
                .verify_password(password.as_bytes(), &parsed_hash)
                .is_ok(),
            Err(_) => false,
        }
    }

    /// Hash a password for storage
    pub fn hash_password(password: &str) -> anyhow::Result<String> {
        use argon2::{
            password_hash::{rand_core::OsRng, SaltString},
            Argon2, PasswordHasher,
        };
        
        let salt = SaltString::generate(&mut OsRng);
        let argon2 = Argon2::default();
        let hash = argon2
            .hash_password(password.as_bytes(), &salt)
            .map_err(|e| anyhow::anyhow!("Password hashing failed: {}", e))?;
        Ok(hash.to_string())
    }

    /// Add a user to the room
    pub async fn join(&self, username: String, cols: u16, rows: u16) -> anyhow::Result<u64> {
        let users = self.users.read().await;
        if users.len() >= self.max_connections {
            anyhow::bail!("Room is full");
        }
        drop(users);

        let mut session_id = self.next_session_id.write().await;
        let id = *session_id;
        *session_id += 1;
        drop(session_id);

        let session = UserSession::new(username.clone(), cols, rows);
        self.users.write().await.insert(id, session);

        // Add system message
        let msg = Message::system(format!("{} joined the room", username));
        self.add_message(msg).await;

        // Broadcast join event
        let _ = self.broadcast_tx.send(RoomEvent::UserJoined(username));

        Ok(id)
    }

    /// Remove a user from the room
    pub async fn leave(&self, session_id: u64) {
        if let Some(session) = self.users.write().await.remove(&session_id) {
            let msg = Message::system(format!("{} left the room", session.username));
            self.add_message(msg).await;
            let _ = self.broadcast_tx.send(RoomEvent::UserLeft(session.username));
        }
    }

    /// Update user's terminal size
    pub async fn update_terminal_size(&self, session_id: u64, cols: u16, rows: u16) {
        if let Some(session) = self.users.write().await.get_mut(&session_id) {
            session.terminal_size = (cols, rows);
        }
        let _ = self.broadcast_tx.send(RoomEvent::Refresh);
    }

    /// Add a message to history and broadcast
    pub async fn add_message(&self, message: Message) {
        let mut messages = self.messages.write().await;
        messages.push(message.clone());
        
        // Trim old messages
        let len = messages.len();
        if len > MAX_HISTORY {
            messages.drain(0..(len - MAX_HISTORY));
        }
        
        drop(messages);
        let _ = self.broadcast_tx.send(RoomEvent::NewMessage(message));
    }

    /// Send a text message
    pub async fn send_message(&self, session_id: u64, content: String) {
        let users = self.users.read().await;
        if let Some(session) = users.get(&session_id) {
            let msg = Message::text(session.username.clone(), content);
            drop(users);
            self.add_message(msg).await;
        }
    }

    /// Get message history
    pub async fn get_messages(&self) -> Vec<Message> {
        self.messages.read().await.clone()
    }

    /// Get list of connected users
    pub async fn get_users(&self) -> Vec<String> {
        self.users
            .read()
            .await
            .values()
            .map(|s| s.username.clone())
            .collect()
    }

    /// Get user count
    pub async fn user_count(&self) -> usize {
        self.users.read().await.len()
    }

    /// Subscribe to room events
    pub fn subscribe(&self) -> broadcast::Receiver<RoomEvent> {
        self.broadcast_tx.subscribe()
    }

    /// Set the tunnel URL
    pub async fn set_tunnel_url(&self, url: String) {
        *self.tunnel_url.write().await = Some(url);
        let _ = self.broadcast_tx.send(RoomEvent::Refresh);
    }
}