1378 lines
47 KiB
Rust
1378 lines
47 KiB
Rust
//! Modulo de Chat em Tempo Real
|
|
//!
|
|
//! Este modulo implementa o sistema de chat entre agentes (dashboard web)
|
|
//! e clientes (Raven desktop). Usa Server-Sent Events (SSE) como metodo
|
|
//! primario para atualizacoes em tempo real, com fallback para HTTP polling.
|
|
|
|
use convex::{ConvexClient, FunctionResult, Value};
|
|
use futures_util::StreamExt;
|
|
use once_cell::sync::Lazy;
|
|
use parking_lot::Mutex;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
use tauri::async_runtime::JoinHandle;
|
|
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
|
|
use tauri_plugin_notification::NotificationExt;
|
|
|
|
// ============================================================================
|
|
// TYPES
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ChatSession {
|
|
pub session_id: String,
|
|
pub ticket_id: String,
|
|
pub ticket_ref: u64,
|
|
pub ticket_subject: String,
|
|
pub agent_name: String,
|
|
pub agent_email: Option<String>,
|
|
pub agent_avatar_url: Option<String>,
|
|
pub unread_count: u32,
|
|
pub last_activity_at: i64,
|
|
pub started_at: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ChatMessage {
|
|
pub id: String,
|
|
pub body: String,
|
|
pub author_name: String,
|
|
pub author_avatar_url: Option<String>,
|
|
pub is_from_machine: bool,
|
|
pub created_at: i64,
|
|
pub attachments: Vec<ChatAttachment>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ChatAttachment {
|
|
pub storage_id: String,
|
|
pub name: String,
|
|
pub size: Option<u64>,
|
|
#[serde(rename = "type")]
|
|
pub mime_type: Option<String>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ChatPollResponse {
|
|
pub has_active_sessions: bool,
|
|
pub sessions: Vec<ChatSessionSummary>,
|
|
pub total_unread: u32,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ChatSessionSummary {
|
|
pub ticket_id: String,
|
|
pub ticket_ref: u64,
|
|
pub unread_count: u32,
|
|
pub last_activity_at: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ChatMessagesResponse {
|
|
pub messages: Vec<ChatMessage>,
|
|
pub has_session: bool,
|
|
#[serde(default)]
|
|
pub unread_count: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SendMessageResponse {
|
|
pub message_id: String,
|
|
pub created_at: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionStartedEvent {
|
|
pub session: ChatSession,
|
|
}
|
|
|
|
// ============================================================================
|
|
// PERSISTENCIA DE ESTADO
|
|
// ============================================================================
|
|
|
|
/// Estado persistido do chat para sobreviver a restarts
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ChatPersistedState {
|
|
last_unread_count: u32,
|
|
sessions: Vec<ChatSession>,
|
|
saved_at: u64, // Unix timestamp em ms
|
|
}
|
|
|
|
const STATE_FILE_NAME: &str = "chat-state.json";
|
|
const STATE_MAX_AGE_MS: u64 = 3600_000; // 1 hora - ignorar estados mais antigos
|
|
|
|
fn get_state_file_path() -> Option<PathBuf> {
|
|
dirs::data_local_dir().map(|p| p.join("Raven").join(STATE_FILE_NAME))
|
|
}
|
|
|
|
fn save_chat_state(last_unread: u32, sessions: &[ChatSession]) {
|
|
let Some(path) = get_state_file_path() else {
|
|
return;
|
|
};
|
|
|
|
// Criar diretorio se nao existir
|
|
if let Some(parent) = path.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
|
|
let state = ChatPersistedState {
|
|
last_unread_count: last_unread,
|
|
sessions: sessions.to_vec(),
|
|
saved_at: now,
|
|
};
|
|
|
|
if let Ok(json) = serde_json::to_string_pretty(&state) {
|
|
let _ = fs::write(&path, json);
|
|
crate::log_info!("[CHAT] Estado persistido: unread={}, sessions={}", last_unread, sessions.len());
|
|
}
|
|
}
|
|
|
|
fn load_chat_state() -> Option<ChatPersistedState> {
|
|
let path = get_state_file_path()?;
|
|
|
|
let json = fs::read_to_string(&path).ok()?;
|
|
let state: ChatPersistedState = serde_json::from_str(&json).ok()?;
|
|
|
|
// Verificar se estado nao esta muito antigo
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
|
|
if now.saturating_sub(state.saved_at) > STATE_MAX_AGE_MS {
|
|
crate::log_info!("[CHAT] Estado persistido ignorado (muito antigo)");
|
|
return None;
|
|
}
|
|
|
|
crate::log_info!(
|
|
"[CHAT] Estado restaurado: unread={}, sessions={}",
|
|
state.last_unread_count, state.sessions.len()
|
|
);
|
|
Some(state)
|
|
}
|
|
|
|
// ============================================================================
|
|
// HTTP CLIENT
|
|
// ============================================================================
|
|
|
|
static CHAT_CLIENT: Lazy<Client> = Lazy::new(|| {
|
|
Client::builder()
|
|
.user_agent("raven-chat/1.0")
|
|
.timeout(Duration::from_secs(15))
|
|
.use_rustls_tls()
|
|
.build()
|
|
.expect("failed to build chat http client")
|
|
});
|
|
|
|
// ============================================================================
|
|
// API FUNCTIONS
|
|
// ============================================================================
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn poll_chat_updates(
|
|
base_url: &str,
|
|
token: &str,
|
|
last_checked_at: Option<i64>,
|
|
) -> Result<ChatPollResponse, String> {
|
|
let url = format!("{}/api/machines/chat/poll", base_url);
|
|
|
|
let mut payload = serde_json::json!({
|
|
"machineToken": token,
|
|
});
|
|
|
|
if let Some(ts) = last_checked_at {
|
|
payload["lastCheckedAt"] = serde_json::json!(ts);
|
|
}
|
|
|
|
let response = CHAT_CLIENT
|
|
.post(&url)
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Falha na requisicao de poll: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Poll falhou: status={}, body={}", status, body));
|
|
}
|
|
|
|
response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Falha ao parsear resposta de poll: {e}"))
|
|
}
|
|
|
|
pub async fn fetch_sessions(base_url: &str, token: &str) -> Result<Vec<ChatSession>, String> {
|
|
let url = format!("{}/api/machines/chat/sessions", base_url);
|
|
|
|
let payload = serde_json::json!({
|
|
"machineToken": token,
|
|
});
|
|
|
|
let response = CHAT_CLIENT
|
|
.post(&url)
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Falha na requisicao de sessions: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Sessions falhou: status={}, body={}", status, body));
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SessionsResponse {
|
|
sessions: Vec<ChatSession>,
|
|
}
|
|
|
|
let data: SessionsResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Falha ao parsear resposta de sessions: {e}"))?;
|
|
|
|
Ok(data.sessions)
|
|
}
|
|
|
|
pub async fn fetch_messages(
|
|
base_url: &str,
|
|
token: &str,
|
|
ticket_id: &str,
|
|
since: Option<i64>,
|
|
) -> Result<ChatMessagesResponse, String> {
|
|
let url = format!("{}/api/machines/chat/messages", base_url);
|
|
|
|
let mut payload = serde_json::json!({
|
|
"machineToken": token,
|
|
"ticketId": ticket_id,
|
|
"action": "list",
|
|
"limit": 200,
|
|
});
|
|
|
|
if let Some(ts) = since {
|
|
payload["since"] = serde_json::json!(ts);
|
|
}
|
|
|
|
let response = CHAT_CLIENT
|
|
.post(&url)
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Falha na requisicao de messages: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Messages falhou: status={}, body={}", status, body));
|
|
}
|
|
|
|
response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Falha ao parsear resposta de messages: {e}"))
|
|
}
|
|
|
|
pub async fn send_message(
|
|
base_url: &str,
|
|
token: &str,
|
|
ticket_id: &str,
|
|
body: &str,
|
|
attachments: Option<Vec<AttachmentPayload>>,
|
|
) -> Result<SendMessageResponse, String> {
|
|
let url = format!("{}/api/machines/chat/messages", base_url);
|
|
|
|
let mut payload = serde_json::json!({
|
|
"machineToken": token,
|
|
"ticketId": ticket_id,
|
|
"action": "send",
|
|
"body": body,
|
|
});
|
|
|
|
if let Some(atts) = attachments {
|
|
payload["attachments"] = serde_json::to_value(atts).unwrap_or_default();
|
|
}
|
|
|
|
let response = CHAT_CLIENT
|
|
.post(&url)
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Falha na requisicao de send: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Send falhou: status={}, body={}", status, body));
|
|
}
|
|
|
|
response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Falha ao parsear resposta de send: {e}"))
|
|
}
|
|
|
|
pub async fn mark_messages_read(
|
|
base_url: &str,
|
|
token: &str,
|
|
ticket_id: &str,
|
|
message_ids: &[String],
|
|
) -> Result<(), String> {
|
|
let url = format!("{}/api/machines/chat/read", base_url);
|
|
|
|
let payload = serde_json::json!({
|
|
"machineToken": token,
|
|
"ticketId": ticket_id,
|
|
"messageIds": message_ids,
|
|
});
|
|
|
|
let response = CHAT_CLIENT
|
|
.post(&url)
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Falha na requisicao de mark read: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Mark read falhou: status={}, body={}", status, body));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// UPLOAD DE ARQUIVOS
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AttachmentPayload {
|
|
pub storage_id: String,
|
|
pub name: String,
|
|
pub size: Option<u64>,
|
|
#[serde(rename = "type")]
|
|
pub mime_type: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UploadUrlResponse {
|
|
pub upload_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UploadResult {
|
|
pub storage_id: String,
|
|
}
|
|
|
|
// Extensoes permitidas
|
|
const ALLOWED_EXTENSIONS: &[&str] = &[
|
|
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
|
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
|
|
];
|
|
|
|
// Tamanho maximo: 10MB
|
|
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
|
|
|
pub fn is_allowed_file(file_name: &str, file_size: u64) -> Result<(), String> {
|
|
let ext = file_name
|
|
.to_lowercase()
|
|
.rsplit('.')
|
|
.next()
|
|
.map(|e| format!(".{}", e))
|
|
.unwrap_or_default();
|
|
|
|
if !ALLOWED_EXTENSIONS.contains(&ext.as_str()) {
|
|
return Err(format!(
|
|
"Tipo de arquivo não permitido. Permitidos: {}",
|
|
ALLOWED_EXTENSIONS.join(", ")
|
|
));
|
|
}
|
|
|
|
if file_size > MAX_FILE_SIZE {
|
|
return Err(format!(
|
|
"Arquivo muito grande. Máximo: {}MB",
|
|
MAX_FILE_SIZE / 1024 / 1024
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_mime_type(file_name: &str) -> String {
|
|
let lower = file_name.to_lowercase();
|
|
let ext = lower.rsplit('.').next().unwrap_or("");
|
|
|
|
match ext {
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"png" => "image/png",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
"pdf" => "application/pdf",
|
|
"txt" => "text/plain",
|
|
"doc" => "application/msword",
|
|
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"xls" => "application/vnd.ms-excel",
|
|
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
_ => "application/octet-stream",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
pub async fn generate_upload_url(
|
|
base_url: &str,
|
|
token: &str,
|
|
file_name: &str,
|
|
file_type: &str,
|
|
file_size: u64,
|
|
) -> Result<String, String> {
|
|
let url = format!("{}/api/machines/chat/upload", base_url);
|
|
|
|
let payload = serde_json::json!({
|
|
"machineToken": token,
|
|
"fileName": file_name,
|
|
"fileType": file_type,
|
|
"fileSize": file_size,
|
|
});
|
|
|
|
let response = CHAT_CLIENT
|
|
.post(&url)
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Falha na requisicao de upload URL: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Upload URL falhou: status={}, body={}", status, body));
|
|
}
|
|
|
|
let data: UploadUrlResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Falha ao parsear resposta de upload URL: {e}"))?;
|
|
|
|
Ok(data.upload_url)
|
|
}
|
|
|
|
pub async fn upload_file(
|
|
upload_url: &str,
|
|
file_data: Vec<u8>,
|
|
content_type: &str,
|
|
) -> Result<String, String> {
|
|
let response = CHAT_CLIENT
|
|
.post(upload_url)
|
|
.header("Content-Type", content_type)
|
|
.body(file_data)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Falha no upload: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Upload falhou: status={}, body={}", status, body));
|
|
}
|
|
|
|
let data: UploadResult = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Falha ao parsear resposta de upload: {e}"))?;
|
|
|
|
Ok(data.storage_id)
|
|
}
|
|
|
|
// ============================================================================
|
|
// CHAT RUNTIME
|
|
// ============================================================================
|
|
|
|
struct ChatRealtimeHandle {
|
|
stop_flag: Arc<AtomicBool>,
|
|
join_handle: JoinHandle<()>,
|
|
}
|
|
|
|
impl ChatRealtimeHandle {
|
|
fn stop(self) {
|
|
self.stop_flag.store(true, Ordering::Relaxed);
|
|
self.join_handle.abort();
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
pub struct ChatRuntime {
|
|
inner: Arc<Mutex<Option<ChatRealtimeHandle>>>,
|
|
last_sessions: Arc<Mutex<Vec<ChatSession>>>,
|
|
last_unread_count: Arc<Mutex<u32>>,
|
|
is_connected: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl ChatRuntime {
|
|
pub fn new() -> Self {
|
|
// Tentar restaurar estado persistido
|
|
let (sessions, unread) = match load_chat_state() {
|
|
Some(state) => (state.sessions, state.last_unread_count),
|
|
None => (Vec::new(), 0),
|
|
};
|
|
|
|
Self {
|
|
inner: Arc::new(Mutex::new(None)),
|
|
last_sessions: Arc::new(Mutex::new(sessions)),
|
|
last_unread_count: Arc::new(Mutex::new(unread)),
|
|
is_connected: Arc::new(AtomicBool::new(false)),
|
|
}
|
|
}
|
|
|
|
/// Retorna true se conexao WS Convex esta ativa
|
|
pub fn is_using_sse(&self) -> bool {
|
|
self.is_connected.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Inicia o sistema de atualizacoes de chat via WebSocket do Convex
|
|
pub fn start_polling(
|
|
&self,
|
|
base_url: String,
|
|
convex_url: String,
|
|
token: String,
|
|
app: tauri::AppHandle,
|
|
) -> Result<(), String> {
|
|
let sanitized_base = base_url.trim().trim_end_matches('/').to_string();
|
|
if sanitized_base.is_empty() {
|
|
return Err("URL base invalida".to_string());
|
|
}
|
|
let sanitized_convex = convex_url.trim().trim_end_matches('/').to_string();
|
|
if sanitized_convex.is_empty() {
|
|
return Err("URL do Convex inválida".to_string());
|
|
}
|
|
|
|
// Para polling/SSE existente
|
|
{
|
|
let mut guard = self.inner.lock();
|
|
if let Some(handle) = guard.take() {
|
|
handle.stop();
|
|
}
|
|
}
|
|
|
|
let stop_flag = Arc::new(AtomicBool::new(false));
|
|
let stop_clone = stop_flag.clone();
|
|
let base_clone = sanitized_base.clone();
|
|
let convex_clone = sanitized_convex.clone();
|
|
let token_clone = token.clone();
|
|
let last_sessions = self.last_sessions.clone();
|
|
let last_unread_count = self.last_unread_count.clone();
|
|
let is_connected = self.is_connected.clone();
|
|
|
|
let join_handle = tauri::async_runtime::spawn(async move {
|
|
crate::log_info!("[CHAT DEBUG] Iniciando sistema de chat");
|
|
crate::log_info!("[CHAT DEBUG] Convex URL: {}", convex_clone);
|
|
crate::log_info!("[CHAT DEBUG] API Base URL: {}", base_clone);
|
|
|
|
let mut backoff_ms: u64 = 1_000;
|
|
let max_backoff_ms: u64 = 30_000;
|
|
let poll_interval = Duration::from_secs(5);
|
|
let mut last_poll = Instant::now() - poll_interval;
|
|
|
|
loop {
|
|
if stop_clone.load(Ordering::Relaxed) {
|
|
break;
|
|
}
|
|
|
|
crate::log_info!("[CHAT DEBUG] Tentando conectar ao Convex...");
|
|
let client_result = ConvexClient::new(&convex_clone).await;
|
|
let mut client = match client_result {
|
|
Ok(c) => {
|
|
crate::log_info!("[CHAT DEBUG] Cliente Convex criado com sucesso");
|
|
c
|
|
}
|
|
Err(err) => {
|
|
is_connected.store(false, Ordering::Relaxed);
|
|
crate::log_warn!("[CHAT DEBUG] FALHA ao criar cliente Convex: {err:?}");
|
|
|
|
if last_poll.elapsed() >= poll_interval {
|
|
poll_and_process_chat_update(
|
|
&base_clone,
|
|
&token_clone,
|
|
&app,
|
|
&last_sessions,
|
|
&last_unread_count,
|
|
)
|
|
.await;
|
|
last_poll = Instant::now();
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
|
|
backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let mut args = BTreeMap::new();
|
|
args.insert("machineToken".to_string(), token_clone.clone().into());
|
|
|
|
crate::log_info!("[CHAT DEBUG] Assinando liveChat:checkMachineUpdates...");
|
|
let subscribe_result = client.subscribe("liveChat:checkMachineUpdates", args).await;
|
|
let mut subscription = match subscribe_result {
|
|
Ok(sub) => {
|
|
is_connected.store(true, Ordering::Relaxed);
|
|
backoff_ms = 1_000;
|
|
crate::log_info!("[CHAT DEBUG] CONECTADO ao Convex WebSocket com sucesso!");
|
|
sub
|
|
}
|
|
Err(err) => {
|
|
is_connected.store(false, Ordering::Relaxed);
|
|
crate::log_warn!("[CHAT DEBUG] FALHA ao assinar checkMachineUpdates: {err:?}");
|
|
|
|
if last_poll.elapsed() >= poll_interval {
|
|
poll_and_process_chat_update(
|
|
&base_clone,
|
|
&token_clone,
|
|
&app,
|
|
&last_sessions,
|
|
&last_unread_count,
|
|
)
|
|
.await;
|
|
last_poll = Instant::now();
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
|
|
backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
crate::log_info!("[CHAT DEBUG] Entrando no loop de escuta WebSocket...");
|
|
let mut update_count: u64 = 0;
|
|
while let Some(next) = subscription.next().await {
|
|
update_count += 1;
|
|
if stop_clone.load(Ordering::Relaxed) {
|
|
crate::log_info!("[CHAT DEBUG] Stop flag detectado, saindo do loop");
|
|
break;
|
|
}
|
|
match next {
|
|
FunctionResult::Value(Value::Object(obj)) => {
|
|
let has_active = obj
|
|
.get("hasActiveSessions")
|
|
.and_then(|v| match v {
|
|
Value::Boolean(b) => Some(*b),
|
|
_ => None,
|
|
})
|
|
.unwrap_or(false);
|
|
let total_unread = obj
|
|
.get("totalUnread")
|
|
.and_then(|v| match v {
|
|
Value::Int64(i) => Some(*i as u32),
|
|
Value::Float64(f) => Some(*f as u32),
|
|
_ => None,
|
|
})
|
|
.unwrap_or(0);
|
|
|
|
crate::log_info!(
|
|
"[CHAT DEBUG] UPDATE #{} recebido via WebSocket: hasActive={}, totalUnread={}",
|
|
update_count, has_active, total_unread
|
|
);
|
|
|
|
process_chat_update(
|
|
&base_clone,
|
|
&token_clone,
|
|
&app,
|
|
&last_sessions,
|
|
&last_unread_count,
|
|
has_active,
|
|
total_unread,
|
|
)
|
|
.await;
|
|
}
|
|
FunctionResult::ConvexError(err) => {
|
|
crate::log_warn!("[CHAT DEBUG] Convex error em checkMachineUpdates: {err:?}");
|
|
}
|
|
FunctionResult::ErrorMessage(msg) => {
|
|
crate::log_warn!("[CHAT DEBUG] Erro em checkMachineUpdates: {msg}");
|
|
}
|
|
FunctionResult::Value(other) => {
|
|
crate::log_warn!("[CHAT DEBUG] Payload inesperado em checkMachineUpdates: {other:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
is_connected.store(false, Ordering::Relaxed);
|
|
|
|
if stop_clone.load(Ordering::Relaxed) {
|
|
crate::log_info!("[CHAT DEBUG] Stop flag detectado apos loop");
|
|
break;
|
|
}
|
|
|
|
crate::log_warn!("[CHAT DEBUG] WebSocket DESCONECTADO! Aplicando fallback e tentando reconectar...");
|
|
if last_poll.elapsed() >= poll_interval {
|
|
poll_and_process_chat_update(
|
|
&base_clone,
|
|
&token_clone,
|
|
&app,
|
|
&last_sessions,
|
|
&last_unread_count,
|
|
)
|
|
.await;
|
|
last_poll = Instant::now();
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
|
|
backoff_ms = backoff_ms.saturating_mul(2).min(max_backoff_ms);
|
|
}
|
|
|
|
is_connected.store(false, Ordering::Relaxed);
|
|
crate::log_info!("Chat encerrado (realtime finalizado)");
|
|
});
|
|
|
|
let mut guard = self.inner.lock();
|
|
*guard = Some(ChatRealtimeHandle {
|
|
stop_flag,
|
|
join_handle,
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stop(&self) {
|
|
let mut guard = self.inner.lock();
|
|
if let Some(handle) = guard.take() {
|
|
handle.stop();
|
|
}
|
|
self.is_connected.store(false, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn get_sessions(&self) -> Vec<ChatSession> {
|
|
self.last_sessions.lock().clone()
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SHARED UPDATE PROCESSING
|
|
// ============================================================================
|
|
|
|
async fn poll_and_process_chat_update(
|
|
base_url: &str,
|
|
token: &str,
|
|
app: &tauri::AppHandle,
|
|
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
|
|
last_unread_count: &Arc<Mutex<u32>>,
|
|
) {
|
|
crate::log_info!("[CHAT DEBUG] Executando fallback HTTP polling...");
|
|
match poll_chat_updates(base_url, token, None).await {
|
|
Ok(result) => {
|
|
crate::log_info!(
|
|
"[CHAT DEBUG] Polling OK: hasActive={}, totalUnread={}",
|
|
result.has_active_sessions, result.total_unread
|
|
);
|
|
process_chat_update(
|
|
base_url,
|
|
token,
|
|
app,
|
|
last_sessions,
|
|
last_unread_count,
|
|
result.has_active_sessions,
|
|
result.total_unread,
|
|
)
|
|
.await;
|
|
}
|
|
Err(err) => {
|
|
crate::log_warn!("[CHAT DEBUG] Fallback poll FALHOU: {err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn process_chat_update(
|
|
base_url: &str,
|
|
token: &str,
|
|
app: &tauri::AppHandle,
|
|
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
|
|
last_unread_count: &Arc<Mutex<u32>>,
|
|
has_active_sessions: bool,
|
|
total_unread: u32,
|
|
) {
|
|
crate::log_info!(
|
|
"[CHAT DEBUG] process_chat_update: hasActive={}, totalUnread={}",
|
|
has_active_sessions, total_unread
|
|
);
|
|
|
|
// Buscar sessoes completas para ter dados corretos
|
|
let mut current_sessions = if has_active_sessions {
|
|
let sessions = fetch_sessions(base_url, token).await.unwrap_or_default();
|
|
crate::log_info!("[CHAT DEBUG] Buscou {} sessoes ativas", sessions.len());
|
|
sessions
|
|
} else {
|
|
crate::log_info!("[CHAT DEBUG] Sem sessoes ativas");
|
|
Vec::new()
|
|
};
|
|
|
|
// Ordenar por ultima atividade (mais recente primeiro) para consistencia em UI/tray.
|
|
if current_sessions.len() > 1 {
|
|
current_sessions.sort_by(|a, b| {
|
|
b.last_activity_at
|
|
.cmp(&a.last_activity_at)
|
|
.then_with(|| b.started_at.cmp(&a.started_at))
|
|
});
|
|
}
|
|
|
|
// Verificar sessoes anteriores
|
|
let prev_sessions: Vec<ChatSession> = last_sessions.lock().clone();
|
|
let prev_session_ids: Vec<String> = prev_sessions.iter().map(|s| s.session_id.clone()).collect();
|
|
let current_session_ids: Vec<String> = current_sessions.iter().map(|s| s.session_id.clone()).collect();
|
|
|
|
// Detectar novas sessoes
|
|
for session in ¤t_sessions {
|
|
if !prev_session_ids.contains(&session.session_id) {
|
|
crate::log_info!(
|
|
"Nova sessao de chat: ticket={}, session={}",
|
|
session.ticket_id,
|
|
session.session_id
|
|
);
|
|
let _ = app.emit(
|
|
"raven://chat/session-started",
|
|
SessionStartedEvent {
|
|
session: session.clone(),
|
|
},
|
|
);
|
|
|
|
// NÃO abre janela aqui - só quando o agente enviar a primeira mensagem
|
|
// O chat aparecerá minimizado com badge quando houver novas mensagens
|
|
|
|
crate::log_info!(
|
|
"Sessão de chat iniciada pelo agente {}. Aguardando primeira mensagem.",
|
|
session.agent_name
|
|
);
|
|
}
|
|
}
|
|
|
|
// Detectar sessoes encerradas
|
|
for prev_session in &prev_sessions {
|
|
if !current_session_ids.contains(&prev_session.session_id) {
|
|
crate::log_info!(
|
|
"Sessao de chat encerrada: ticket={}, session={}",
|
|
prev_session.ticket_id,
|
|
prev_session.session_id
|
|
);
|
|
let _ = app.emit(
|
|
"raven://chat/session-ended",
|
|
serde_json::json!({
|
|
"sessionId": prev_session.session_id,
|
|
"ticketId": prev_session.ticket_id
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// DETECCAO ROBUSTA DE NOVAS MENSAGENS
|
|
// Usa DUAS estrategias: timestamp E contador (belt and suspenders)
|
|
// =========================================================================
|
|
|
|
let prev_unread = *last_unread_count.lock();
|
|
|
|
// Estrategia 1: Detectar por lastActivityAt de cada sessao
|
|
// Se alguma sessao teve atividade mais recente E tem mensagens nao lidas -> nova mensagem
|
|
let mut detected_by_activity = false;
|
|
let mut activity_details = String::new();
|
|
|
|
for session in ¤t_sessions {
|
|
let prev_activity = prev_sessions
|
|
.iter()
|
|
.find(|s| s.session_id == session.session_id)
|
|
.map(|s| s.last_activity_at)
|
|
.unwrap_or(0);
|
|
|
|
// Se lastActivityAt aumentou E ha mensagens nao lidas -> nova mensagem do agente
|
|
if session.last_activity_at > prev_activity && session.unread_count > 0 {
|
|
detected_by_activity = true;
|
|
activity_details = format!(
|
|
"sessao={} activity: {} -> {} unread={}",
|
|
session.ticket_id, prev_activity, session.last_activity_at, session.unread_count
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Estrategia 2: Fallback por contador total (metodo original)
|
|
let detected_by_count = total_unread > prev_unread;
|
|
|
|
// Nova mensagem se QUALQUER estrategia detectar
|
|
let new_messages = detected_by_activity || detected_by_count;
|
|
|
|
// Log detalhado para diagnostico
|
|
crate::log_info!(
|
|
"[CHAT] Deteccao: by_activity={} by_count={} (prev={} curr={}) resultado={}",
|
|
detected_by_activity, detected_by_count, prev_unread, total_unread, new_messages
|
|
);
|
|
if detected_by_activity {
|
|
crate::log_info!("[CHAT] Detectado por atividade: {}", activity_details);
|
|
}
|
|
|
|
// Atualizar caches APOS deteccao (importante: manter ordem)
|
|
*last_sessions.lock() = current_sessions.clone();
|
|
*last_unread_count.lock() = total_unread;
|
|
|
|
// Persistir estado para sobreviver a restarts
|
|
save_chat_state(total_unread, ¤t_sessions);
|
|
|
|
// Sempre emitir unread-update
|
|
let _ = app.emit(
|
|
"raven://chat/unread-update",
|
|
serde_json::json!({
|
|
"totalUnread": total_unread,
|
|
"sessions": current_sessions
|
|
}),
|
|
);
|
|
|
|
// Notificar novas mensagens - mostrar chat minimizado com badge
|
|
if new_messages && total_unread > 0 {
|
|
let new_count = if total_unread > prev_unread {
|
|
total_unread - prev_unread
|
|
} else {
|
|
1 // Se detectou por activity mas contador nao mudou, assumir 1 nova
|
|
};
|
|
|
|
crate::log_info!(
|
|
"[CHAT] NOVAS MENSAGENS! count={}, total={}, metodo={}",
|
|
new_count, total_unread,
|
|
if detected_by_activity { "activity" } else { "count" }
|
|
);
|
|
|
|
let _ = app.emit(
|
|
"raven://chat/new-message",
|
|
serde_json::json!({
|
|
"totalUnread": total_unread,
|
|
"newCount": new_count,
|
|
"sessions": current_sessions
|
|
}),
|
|
);
|
|
|
|
// Escolher qual sessao/ticket deve ser mostrado quando ha multiplas sessoes.
|
|
// Preferencia: maior incremento de unread (delta) e, em empate, ultima atividade mais recente.
|
|
let mut best_session: Option<&ChatSession> = None;
|
|
let mut best_delta: u32 = 0;
|
|
|
|
for session in ¤t_sessions {
|
|
let prev_unread_for_ticket = prev_sessions
|
|
.iter()
|
|
.find(|s| s.ticket_id == session.ticket_id)
|
|
.map(|s| s.unread_count)
|
|
.unwrap_or(0);
|
|
let delta = session.unread_count.saturating_sub(prev_unread_for_ticket);
|
|
|
|
let is_better = if delta > best_delta {
|
|
true
|
|
} else if delta == best_delta {
|
|
match best_session {
|
|
Some(best) => session.last_activity_at > best.last_activity_at,
|
|
None => true,
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if is_better {
|
|
best_delta = delta;
|
|
best_session = Some(session);
|
|
}
|
|
}
|
|
|
|
// Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual.
|
|
//
|
|
// Importante (UX): em multiplas sessoes, NAO fecha a janela ativa quando chega mensagem em outra conversa.
|
|
// O hub + badge/notificacao sinalizam novas mensagens e o usuario decide quando alternar.
|
|
if current_sessions.len() > 1 {
|
|
let _ = open_hub_window(app);
|
|
} else {
|
|
// Uma sessao - nao precisa de hub
|
|
let _ = close_hub_window(app);
|
|
|
|
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
|
|
let session_to_show = if best_delta > 0 {
|
|
best_session
|
|
} else {
|
|
current_sessions.iter().max_by(|a, b| {
|
|
a.unread_count
|
|
.cmp(&b.unread_count)
|
|
.then_with(|| a.last_activity_at.cmp(&b.last_activity_at))
|
|
})
|
|
};
|
|
|
|
// Mostrar janela de chat (sempre minimizada/nao intrusiva)
|
|
if let Some(session) = session_to_show {
|
|
let _ = open_chat_window_internal(app, &session.ticket_id, session.ticket_ref, true);
|
|
}
|
|
}
|
|
|
|
// Notificacao nativa
|
|
let notification_title = "Nova mensagem de suporte";
|
|
let notification_body = if new_count == 1 {
|
|
"Você recebeu 1 nova mensagem no chat".to_string()
|
|
} else {
|
|
format!("Você recebeu {} novas mensagens no chat", new_count)
|
|
};
|
|
let _ = app
|
|
.notification()
|
|
.builder()
|
|
.title(notification_title)
|
|
.body(¬ification_body)
|
|
.show();
|
|
} else {
|
|
// Log para debug quando NAO ha novas mensagens
|
|
if total_unread == 0 {
|
|
crate::log_info!("[CHAT DEBUG] Sem mensagens nao lidas (total=0)");
|
|
} else if !new_messages {
|
|
crate::log_info!(
|
|
"[CHAT DEBUG] Sem novas mensagens (prev={} >= total={})",
|
|
prev_unread, total_unread
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// WINDOW MANAGEMENT
|
|
// ============================================================================
|
|
|
|
// Serializa operacoes de janela para evitar race/deadlock no Windows (winit/WebView2).
|
|
static WINDOW_OP_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
|
|
|
fn hide_other_chat_windows(app: &tauri::AppHandle, active_label: &str) {
|
|
for (label, window) in app.webview_windows() {
|
|
if !label.starts_with("chat-") {
|
|
continue;
|
|
}
|
|
if label == active_label {
|
|
continue;
|
|
}
|
|
let _ = window.hide();
|
|
}
|
|
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
|
let _ = hub.hide();
|
|
}
|
|
}
|
|
|
|
fn resolve_chat_window_position(
|
|
app: &tauri::AppHandle,
|
|
window: Option<&tauri::WebviewWindow>,
|
|
width: f64,
|
|
height: f64,
|
|
) -> (f64, f64) {
|
|
let margin = 20.0;
|
|
let taskbar_height = 50.0;
|
|
|
|
let monitor = window
|
|
.and_then(|w| w.current_monitor().ok().flatten())
|
|
.or_else(|| {
|
|
app.get_webview_window("main")
|
|
.and_then(|w| w.current_monitor().ok().flatten())
|
|
})
|
|
.or_else(|| app.available_monitors().ok().and_then(|monitors| monitors.into_iter().next()));
|
|
|
|
let Some(monitor) = monitor else {
|
|
return (100.0, 100.0);
|
|
};
|
|
|
|
let size = monitor.size();
|
|
let pos = monitor.position();
|
|
let scale = monitor.scale_factor();
|
|
|
|
// Converter coordenadas do monitor para coordenadas logicas (multi-monitor pode ter origem negativa).
|
|
let monitor_x = pos.x as f64 / scale;
|
|
let monitor_y = pos.y as f64 / scale;
|
|
let monitor_width = size.width as f64 / scale;
|
|
let monitor_height = size.height as f64 / scale;
|
|
|
|
let max_x = monitor_x + monitor_width - width - margin;
|
|
let max_y = monitor_y + monitor_height - height - margin - taskbar_height;
|
|
|
|
let x = if max_x.is_finite() { max_x.max(monitor_x) } else { 100.0 };
|
|
let y = if max_y.is_finite() { max_y.max(monitor_y) } else { 100.0 };
|
|
|
|
(x, y)
|
|
}
|
|
|
|
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
|
|
let _guard = WINDOW_OP_LOCK.lock();
|
|
open_chat_window_with_state(app, ticket_id, ticket_ref, start_minimized)
|
|
}
|
|
|
|
/// Abre janela de chat com estado inicial de minimizacao configuravel
|
|
fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64, start_minimized: bool) -> Result<(), String> {
|
|
let label = format!("chat-{}", ticket_id);
|
|
crate::log_info!(
|
|
"[WINDOW] open_chat_window: label={} ticket_ref={} start_minimized={}",
|
|
label,
|
|
ticket_ref,
|
|
start_minimized
|
|
);
|
|
|
|
if !start_minimized {
|
|
hide_other_chat_windows(app, &label);
|
|
}
|
|
|
|
// Verificar se ja existe
|
|
if let Some(window) = app.get_webview_window(&label) {
|
|
let _ = window.set_ignore_cursor_events(false);
|
|
crate::log_info!("[WINDOW] {}: window existe -> show()", label);
|
|
window.show().map_err(|e| e.to_string())?;
|
|
let _ = window.unminimize();
|
|
if !start_minimized {
|
|
crate::log_info!("[WINDOW] {}: window existe -> set_focus()", label);
|
|
window.set_focus().map_err(|e| e.to_string())?;
|
|
}
|
|
// Expandir a janela se estiver minimizada (quando clicado na lista)
|
|
if !start_minimized {
|
|
crate::log_info!("[WINDOW] {}: window existe -> set_chat_minimized(false)", label);
|
|
let _ = set_chat_minimized_unlocked(app, ticket_id, false);
|
|
}
|
|
crate::log_info!("[WINDOW] {}: open_chat_window OK (reuso)", label);
|
|
return Ok(());
|
|
}
|
|
|
|
// Dimensoes baseadas no estado inicial
|
|
let (width, height) = if start_minimized {
|
|
(240.0, 52.0) // Tamanho minimizado (chip com badge)
|
|
} else {
|
|
(380.0, 520.0) // Tamanho expandido
|
|
};
|
|
|
|
// Posicionar no canto inferior direito (acima da barra de tarefas).
|
|
let (x, y) = resolve_chat_window_position(app, None, width, height);
|
|
|
|
// Usar query param ao inves de path para compatibilidade com SPA
|
|
let url_path = format!("index.html?view=chat&ticketId={}&ticketRef={}", ticket_id, ticket_ref);
|
|
|
|
crate::log_info!(
|
|
"[WINDOW] {}: build() inicio size={}x{} pos=({},{}) url={}",
|
|
label,
|
|
width,
|
|
height,
|
|
x,
|
|
y,
|
|
url_path
|
|
);
|
|
|
|
let window = WebviewWindowBuilder::new(
|
|
app,
|
|
&label,
|
|
WebviewUrl::App(url_path.into()),
|
|
)
|
|
.title("Chat de Suporte")
|
|
.inner_size(width, height) // Abre ja no tamanho correto
|
|
.min_inner_size(240.0, 52.0) // Tamanho minimo para modo minimizado com badge
|
|
.position(x, y)
|
|
.decorations(false) // Sem decoracoes nativas - usa header customizado
|
|
.transparent(true) // Permite fundo transparente
|
|
.shadow(false) // Desabilitar sombra para transparencia funcionar corretamente
|
|
.resizable(false) // Desabilitar redimensionamento manual
|
|
// Mantem o chat acessivel mesmo ao trocar de janela/app (skip_taskbar=true).
|
|
.always_on_top(true)
|
|
.skip_taskbar(true)
|
|
.focused(!start_minimized)
|
|
.visible(true)
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
crate::log_info!("[WINDOW] {}: build() OK", label);
|
|
|
|
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
|
|
let _ = window.set_ignore_cursor_events(false);
|
|
|
|
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) inicio", label, start_minimized);
|
|
// Reaplica layout/posicao logo apos criar a janela.
|
|
// Isso evita que a primeira abertura apareca no canto superior esquerdo em alguns ambientes.
|
|
let _ = set_chat_minimized_unlocked(app, ticket_id, start_minimized);
|
|
crate::log_info!("[WINDOW] {}: pos-build set_chat_minimized({}) fim", label, start_minimized);
|
|
|
|
crate::log_info!("Janela de chat aberta (minimizada={}): {}", start_minimized, label);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str, ticket_ref: u64) -> Result<(), String> {
|
|
// Quando chamado explicitamente (ex: clique no hub), abre expandida
|
|
open_chat_window_internal(app, ticket_id, ticket_ref, false)
|
|
}
|
|
|
|
pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
|
|
let _guard = WINDOW_OP_LOCK.lock();
|
|
let label = format!("chat-{}", ticket_id);
|
|
if let Some(window) = app.get_webview_window(&label) {
|
|
window.close().map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
|
|
let _guard = WINDOW_OP_LOCK.lock();
|
|
let label = format!("chat-{}", ticket_id);
|
|
if let Some(window) = app.get_webview_window(&label) {
|
|
window.hide().map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Redimensiona a janela de chat para modo minimizado (chip) ou expandido
|
|
fn set_chat_minimized_unlocked(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
|
|
let label = format!("chat-{}", ticket_id);
|
|
let window = app.get_webview_window(&label).ok_or("Janela não encontrada")?;
|
|
|
|
if minimized {
|
|
hide_other_chat_windows(app, &label);
|
|
}
|
|
|
|
// Tamanhos - chip minimizado com margem extra para badge (absolute -top-1 -right-1)
|
|
let (width, height) = if minimized {
|
|
(240.0, 52.0) // Tamanho com folga para "Ticket #XXX" e badge
|
|
} else {
|
|
(380.0, 520.0) // Tamanho expandido
|
|
};
|
|
|
|
// Calcular posicao no canto inferior direito do monitor atual (com fallback seguro).
|
|
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
|
|
|
|
// Aplicar novo tamanho e posicao
|
|
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size inicio", label, minimized);
|
|
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
|
|
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_size OK", label, minimized);
|
|
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position inicio", label, minimized);
|
|
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
|
|
crate::log_info!("[WINDOW] {}: set_chat_minimized({}) set_position OK", label, minimized);
|
|
|
|
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bool) -> Result<(), String> {
|
|
let _guard = WINDOW_OP_LOCK.lock();
|
|
set_chat_minimized_unlocked(app, ticket_id, minimized)
|
|
}
|
|
|
|
// ============================================================================
|
|
// HUB WINDOW MANAGEMENT (Lista de todas as sessoes)
|
|
// ============================================================================
|
|
|
|
const HUB_WINDOW_LABEL: &str = "chat-hub";
|
|
|
|
pub fn open_hub_window(app: &tauri::AppHandle) -> Result<(), String> {
|
|
let _guard = WINDOW_OP_LOCK.lock();
|
|
open_hub_window_with_state(app, true) // Por padrao abre minimizada
|
|
}
|
|
|
|
fn open_hub_window_with_state(app: &tauri::AppHandle, start_minimized: bool) -> Result<(), String> {
|
|
// Verificar se ja existe
|
|
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
|
let _ = window.set_ignore_cursor_events(false);
|
|
window.show().map_err(|e| e.to_string())?;
|
|
let _ = window.unminimize();
|
|
if !start_minimized {
|
|
window.set_focus().map_err(|e| e.to_string())?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Dimensoes baseadas no estado inicial
|
|
let (width, height) = if start_minimized {
|
|
(200.0, 52.0) // Tamanho minimizado (chip)
|
|
} else {
|
|
(400.0, 520.0) // Tamanho expandido (igual ao web)
|
|
};
|
|
|
|
// Posicionar no canto inferior direito
|
|
let (x, y) = resolve_chat_window_position(app, None, width, height);
|
|
|
|
// URL para modo hub
|
|
let url_path = "index.html?view=chat&hub=true";
|
|
|
|
WebviewWindowBuilder::new(
|
|
app,
|
|
HUB_WINDOW_LABEL,
|
|
WebviewUrl::App(url_path.into()),
|
|
)
|
|
.title("Chats de Suporte")
|
|
.inner_size(width, height)
|
|
.min_inner_size(200.0, 52.0)
|
|
.position(x, y)
|
|
.decorations(false)
|
|
.transparent(true)
|
|
.shadow(false)
|
|
.resizable(false) // Desabilitar redimensionamento manual
|
|
// Mantem o hub acessivel mesmo ao trocar de janela/app (skip_taskbar=true).
|
|
.always_on_top(true)
|
|
.skip_taskbar(true)
|
|
.focused(!start_minimized)
|
|
.visible(true)
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
// IMPORTANTE: Garantir que a janela receba eventos de cursor (evita click-through)
|
|
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
|
let _ = hub.set_ignore_cursor_events(false);
|
|
if !start_minimized {
|
|
let _ = hub.set_focus();
|
|
}
|
|
}
|
|
|
|
// REMOVIDO TEMPORARIAMENTE: set_hub_minimized logo apos build pode causar
|
|
// "resize em cima do resize" no timing errado do WebView2
|
|
// let _ = set_hub_minimized(app, start_minimized);
|
|
|
|
crate::log_info!("Hub window aberta (minimizada={})", start_minimized);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn close_hub_window(app: &tauri::AppHandle) -> Result<(), String> {
|
|
let _guard = WINDOW_OP_LOCK.lock();
|
|
if let Some(window) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
|
window.close().map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_hub_minimized(app: &tauri::AppHandle, minimized: bool) -> Result<(), String> {
|
|
let _guard = WINDOW_OP_LOCK.lock();
|
|
let window = app.get_webview_window(HUB_WINDOW_LABEL).ok_or("Hub window não encontrada")?;
|
|
|
|
let (width, height) = if minimized {
|
|
(200.0, 52.0) // Chip minimizado
|
|
} else {
|
|
(400.0, 520.0) // Lista expandida (igual ao web)
|
|
};
|
|
|
|
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
|
|
|
|
// IGUAL AO CHAT: primeiro size, depois position (ordem importa para hit-test no Windows)
|
|
window.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
|
|
window.set_position(tauri::LogicalPosition::new(x, y)).map_err(|e| e.to_string())?;
|
|
|
|
// Foco apenas quando expandir (evita roubar foco ao minimizar apos abrir um chat).
|
|
if !minimized {
|
|
let _ = window.set_focus();
|
|
}
|
|
|
|
crate::log_info!("Hub -> minimized={}, size={}x{}, pos=({},{})", minimized, width, height, x, y);
|
|
Ok(())
|
|
}
|