sistema-de-chamados/apps/desktop/src-tauri/src/chat.rs

1031 lines
34 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::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
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,
}
// ============================================================================
// 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 {
Self {
inner: Arc::new(Mutex::new(None)),
last_sessions: Arc::new(Mutex::new(Vec::new())),
last_unread_count: Arc::new(Mutex::new(0)),
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 iniciando (Convex realtime + fallback por polling)");
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;
}
let client_result = ConvexClient::new(&convex_clone).await;
let mut client = match client_result {
Ok(c) => c,
Err(err) => {
is_connected.store(false, Ordering::Relaxed);
crate::log_warn!("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());
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;
sub
}
Err(err) => {
is_connected.store(false, Ordering::Relaxed);
crate::log_warn!("Falha ao assinar liveChat: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;
}
};
while let Some(next) = subscription.next().await {
if stop_clone.load(Ordering::Relaxed) {
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);
process_chat_update(
&base_clone,
&token_clone,
&app,
&last_sessions,
&last_unread_count,
has_active,
total_unread,
)
.await;
}
FunctionResult::ConvexError(err) => {
crate::log_warn!("Convex error em checkMachineUpdates: {err:?}");
}
FunctionResult::ErrorMessage(msg) => {
crate::log_warn!("Erro em checkMachineUpdates: {msg}");
}
FunctionResult::Value(other) => {
crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}");
}
}
}
is_connected.store(false, Ordering::Relaxed);
if stop_clone.load(Ordering::Relaxed) {
break;
}
crate::log_warn!("Chat realtime 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>>,
) {
match poll_chat_updates(base_url, token, None).await {
Ok(result) => {
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 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,
) {
// Buscar sessoes completas para ter dados corretos
let mut current_sessions = if has_active_sessions {
fetch_sessions(base_url, token).await.unwrap_or_default()
} else {
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 &current_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
}),
);
}
}
// Atualizar cache de sessoes
*last_sessions.lock() = current_sessions.clone();
// Verificar mensagens nao lidas
let prev_unread = *last_unread_count.lock();
let new_messages = total_unread > prev_unread;
*last_unread_count.lock() = total_unread;
// 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 = total_unread - prev_unread;
crate::log_info!("Chat: {} novas mensagens (total={})", new_count, total_unread);
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 &current_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);
}
}
// 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 (se nao existe, cria minimizada; se existe, apenas mostra)
if let Some(session) = session_to_show {
let label = format!("chat-{}", session.ticket_id);
if let Some(window) = app.get_webview_window(&label) {
// Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida)
// Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens
let _ = window.show();
// Verificar se esta expandida (altura > 100px significa expandido)
// Se estiver expandida, NAO minimizar - usuario esta usando o chat
if let Ok(size) = window.inner_size() {
let is_expanded = size.height > 100;
if !is_expanded {
// Janela esta minimizada, manter minimizada
let _ = set_chat_minimized(app, &session.ticket_id, true);
}
// Se esta expandida, nao faz nada - deixa o usuario continuar usando
}
} else {
// Criar nova janela ja minimizada (menos intrusivo)
let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref);
}
}
// 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(&notification_body)
.show();
}
}
// ============================================================================
// WINDOW MANAGEMENT
// ============================================================================
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) -> Result<(), String> {
open_chat_window_with_state(app, ticket_id, ticket_ref, true) // Por padrao abre minimizada
}
/// 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);
// Verificar se ja existe
if let Some(window) = app.get_webview_window(&label) {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
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);
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
.always_on_top(true)
.skip_taskbar(true)
.focused(true)
.visible(true)
.build()
.map_err(|e| e.to_string())?;
// 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(app, ticket_id, 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> {
open_chat_window_internal(app, ticket_id, ticket_ref)
}
pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
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 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
pub fn set_chat_minimized(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")?;
// 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
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())?;
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
Ok(())
}