fix(chat): melhora realtime e anexos no desktop

This commit is contained in:
esdrasrenan 2025-12-12 21:36:32 -03:00
parent 3d45fe3b04
commit 8cf13c43de
5 changed files with 603 additions and 141 deletions

View file

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
use tauri_plugin_notification::NotificationExt;
@ -509,79 +509,145 @@ impl ChatRuntime {
let is_connected = self.is_connected.clone();
let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("Chat iniciando via Convex WebSocket");
crate::log_info!("Chat iniciando (Convex realtime + fallback por polling)");
let client_result = ConvexClient::new(&convex_clone).await;
let mut client = match client_result {
Ok(c) => c,
Err(err) => {
crate::log_warn!("Falha ao criar cliente Convex: {err:?}");
return;
}
};
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;
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);
sub
}
Err(err) => {
crate::log_warn!("Falha ao assinar liveChat:checkMachineUpdates: {err:?}");
return;
}
};
while let Some(next) = subscription.next().await {
loop {
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;
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;
}
FunctionResult::ConvexError(err) => {
crate::log_warn!("Convex error em checkMachineUpdates: {err:?}");
};
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
}
FunctionResult::ErrorMessage(msg) => {
crate::log_warn!("Erro em checkMachineUpdates: {msg}");
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;
}
FunctionResult::Value(other) => {
crate::log_warn!("Payload inesperado em checkMachineUpdates: {other:?}");
};
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 (Convex WebSocket finalizado)");
crate::log_info!("Chat encerrado (realtime finalizado)");
});
let mut guard = self.inner.lock();
@ -610,6 +676,32 @@ impl ChatRuntime {
// 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,
@ -620,12 +712,21 @@ async fn process_chat_update(
total_unread: u32,
) {
// Buscar sessoes completas para ter dados corretos
let current_sessions = if has_active_sessions {
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();
@ -706,8 +807,49 @@ async fn process_chat_update(
}),
);
// 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) = current_sessions.first() {
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)
@ -749,6 +891,46 @@ async fn process_chat_update(
// 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
}
@ -771,22 +953,8 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
(380.0, 520.0) // Tamanho expandido
};
// Obter tamanho da tela para posicionar no canto inferior direito
let monitors = app.available_monitors().map_err(|e| e.to_string())?;
let primary = monitors.into_iter().next();
let (x, y) = if let Some(monitor) = primary {
let size = monitor.size();
let scale = monitor.scale_factor();
let margin = 20.0;
let taskbar_height = 50.0;
(
(size.width as f64 / scale) - width - margin,
(size.height as f64 / scale) - height - margin - taskbar_height,
)
} else {
(100.0, 100.0)
};
// 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);
@ -810,6 +978,10 @@ fn open_chat_window_with_state(app: &tauri::AppHandle, ticket_id: &str, ticket_r
.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(())
}
@ -846,19 +1018,8 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo
(380.0, 520.0) // Tamanho expandido
};
// Calcular posicao no canto inferior direito
let (x, y) = if let Some(monitor) = window.current_monitor().ok().flatten() {
let size = monitor.size();
let scale = monitor.scale_factor();
let margin = 20.0;
let taskbar_height = 50.0;
(
(size.width as f64 / scale) - width - margin,
(size.height as f64 / scale) - height - margin - taskbar_height,
)
} else {
(100.0, 100.0)
};
// 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())?;