fix(chat): melhora realtime e anexos no desktop
This commit is contained in:
parent
3d45fe3b04
commit
8cf13c43de
5 changed files with 603 additions and 141 deletions
|
|
@ -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 ¤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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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())?;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog"
|
||||
import { open as openExternal } from "@tauri-apps/plugin-opener"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react"
|
||||
import type { ChatMessage, ChatMessagesResponse, NewMessageEvent } from "./types"
|
||||
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react"
|
||||
import type { ChatAttachment, ChatMessage, ChatMessagesResponse, NewMessageEvent, SessionEndedEvent } from "./types"
|
||||
import { getMachineStoreConfig } from "./machineStore"
|
||||
|
||||
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
|
||||
|
|
@ -32,6 +33,170 @@ function getFileIcon(fileName: string) {
|
|||
return <File className="size-4" />
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: ChatAttachment) {
|
||||
if (attachment.type?.startsWith("image/")) return true
|
||||
const ext = attachment.name.toLowerCase().split(".").pop() ?? ""
|
||||
return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext)
|
||||
}
|
||||
|
||||
function formatAttachmentSize(size?: number) {
|
||||
if (!size) return null
|
||||
if (size < 1024) return `${size}B`
|
||||
const kb = size / 1024
|
||||
if (kb < 1024) return `${Math.round(kb)}KB`
|
||||
return `${(kb / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
function MessageAttachment({
|
||||
attachment,
|
||||
isAgent,
|
||||
loadUrl,
|
||||
}: {
|
||||
attachment: ChatAttachment
|
||||
isAgent: boolean
|
||||
loadUrl: (storageId: string) => Promise<string>
|
||||
}) {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [downloaded, setDownloaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
loadUrl(attachment.storageId)
|
||||
.then((resolved) => {
|
||||
if (!cancelled) setUrl(resolved)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Falha ao carregar URL do anexo:", err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [attachment.storageId, loadUrl])
|
||||
|
||||
const handleView = async () => {
|
||||
if (!url) return
|
||||
try {
|
||||
await openExternal(url)
|
||||
} catch (err) {
|
||||
console.error("Falha ao abrir anexo:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!url || downloading) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
const downloadUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = downloadUrl
|
||||
a.download = attachment.name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
setDownloaded(true)
|
||||
setTimeout(() => setDownloaded(false), 2000)
|
||||
} catch (err) {
|
||||
console.error("Falha ao baixar anexo:", err)
|
||||
// Fallback: abrir no navegador/sistema
|
||||
await handleView()
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sizeLabel = formatAttachmentSize(attachment.size)
|
||||
const isImage = isImageAttachment(attachment)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="truncate">Carregando anexo...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isImage && url) {
|
||||
return (
|
||||
<div className={`group relative overflow-hidden rounded-lg border ${isAgent ? "border-white/10" : "border-slate-200"}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt={attachment.name}
|
||||
className="size-24 cursor-pointer object-cover"
|
||||
onClick={handleView}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={handleView}
|
||||
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
|
||||
title="Visualizar"
|
||||
>
|
||||
<Eye className="size-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-60"
|
||||
title="Baixar"
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="size-4 animate-spin text-white" />
|
||||
) : downloaded ? (
|
||||
<Check className="size-4 text-emerald-300" />
|
||||
) : (
|
||||
<Download className="size-4 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
|
||||
{getFileIcon(attachment.name)}
|
||||
<button onClick={handleView} className="flex-1 truncate text-left hover:underline" title="Visualizar">
|
||||
{attachment.name}
|
||||
</button>
|
||||
{sizeLabel && <span className="text-xs opacity-60">({sizeLabel})</span>}
|
||||
<div className="ml-1 flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleView}
|
||||
className={`flex size-7 items-center justify-center rounded-md ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
|
||||
title="Visualizar"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className={`flex size-7 items-center justify-center rounded-md disabled:opacity-60 ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
|
||||
title="Baixar"
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : downloaded ? (
|
||||
<Check className="size-4 text-emerald-500" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatWidgetProps {
|
||||
ticketId: string
|
||||
ticketRef?: number
|
||||
|
|
@ -89,6 +254,37 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
return cfg
|
||||
}, [])
|
||||
|
||||
const attachmentUrlCacheRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
const loadAttachmentUrl = useCallback(async (storageId: string) => {
|
||||
const cached = attachmentUrlCacheRef.current.get(storageId)
|
||||
if (cached) return cached
|
||||
|
||||
const cfg = await ensureConfig()
|
||||
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
machineToken: cfg.token,
|
||||
ticketId,
|
||||
storageId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "")
|
||||
throw new Error(text || `Falha ao obter URL do anexo (${response.status})`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { url?: string }
|
||||
if (!data.url) {
|
||||
throw new Error("Resposta invalida ao obter URL do anexo")
|
||||
}
|
||||
|
||||
attachmentUrlCacheRef.current.set(storageId, data.url)
|
||||
return data.url
|
||||
}, [ensureConfig, ticketId])
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await ensureConfig()
|
||||
|
|
@ -151,6 +347,24 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
}
|
||||
}, [ticketId, loadMessages])
|
||||
|
||||
// Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
listen<SessionEndedEvent>("raven://chat/session-ended", (event) => {
|
||||
if (event.payload?.ticketId === ticketId) {
|
||||
loadMessages()
|
||||
}
|
||||
})
|
||||
.then((u) => {
|
||||
unlisten = u
|
||||
})
|
||||
.catch((err) => console.error("Falha ao registrar listener session-ended:", err))
|
||||
|
||||
return () => {
|
||||
unlisten?.()
|
||||
}
|
||||
}, [ticketId, loadMessages])
|
||||
|
||||
// Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions
|
||||
/* useEffect(() => {
|
||||
setIsLoading(true)
|
||||
|
|
@ -242,7 +456,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
if (isUploading || isSending) return
|
||||
|
||||
try {
|
||||
const selected = await open({
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{
|
||||
name: "Arquivos permitidos",
|
||||
|
|
@ -513,22 +727,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
|
||||
{/* Anexos */}
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="mt-2 space-y-2">
|
||||
{msg.attachments.map((att) => (
|
||||
<div
|
||||
<MessageAttachment
|
||||
key={att.storageId}
|
||||
className={`flex items-center gap-2 rounded-lg p-2 text-xs ${
|
||||
isAgent ? "bg-white/10" : "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{getFileIcon(att.name)}
|
||||
<span className="truncate">{att.name}</span>
|
||||
{att.size && (
|
||||
<span className="text-xs opacity-60">
|
||||
({Math.round(att.size / 1024)}KB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
attachment={att}
|
||||
isAgent={isAgent}
|
||||
loadUrl={loadAttachmentUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue