feat(desktop): adiciona hub de chats para multiplas sessoes
- Cria ChatSessionList, ChatSessionItem e ChatHubWidget no desktop - Adiciona comandos Rust para gerenciar hub window - Quando ha multiplas sessoes, abre hub ao inves de janela individual - Hub lista todas as sessoes ativas com badge de nao lidos - Clicar em sessao abre/foca janela de chat especifica - Menu do tray abre hub quando ha multiplas sessoes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
95ab1b5f0c
commit
29fbbfaa26
6 changed files with 560 additions and 38 deletions
|
|
@ -1000,37 +1000,58 @@ async fn process_chat_update(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: se nao conseguimos detectar delta, pega a sessao com mais unread e mais recente.
|
// Se ha multiplas sessoes ativas, usar o hub; senao, abrir janela do chat individual
|
||||||
let session_to_show = if best_delta > 0 {
|
if current_sessions.len() > 1 {
|
||||||
best_session
|
// Multiplas sessoes - usar hub window
|
||||||
} else {
|
if app.get_webview_window(HUB_WINDOW_LABEL).is_none() {
|
||||||
current_sessions.iter().max_by(|a, b| {
|
// Hub nao existe - criar minimizado
|
||||||
a.unread_count
|
let _ = open_hub_window(app);
|
||||||
.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 {
|
} else {
|
||||||
// Criar nova janela ja minimizada (menos intrusivo)
|
// Hub ja existe - verificar se esta minimizado
|
||||||
let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref);
|
if let Some(hub) = app.get_webview_window(HUB_WINDOW_LABEL) {
|
||||||
|
let _ = hub.show();
|
||||||
|
if let Ok(size) = hub.inner_size() {
|
||||||
|
if size.height < 100 {
|
||||||
|
// Esta minimizado, manter assim
|
||||||
|
let _ = set_hub_minimized(app, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Uma sessao - abrir janela individual
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1201,3 +1222,85 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo
|
||||||
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
|
crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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> {
|
||||||
|
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) {
|
||||||
|
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 {
|
||||||
|
(200.0, 52.0) // Tamanho minimizado (chip)
|
||||||
|
} else {
|
||||||
|
(380.0, 480.0) // Tamanho expandido (lista)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.focused(true)
|
||||||
|
.visible(true)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Reaplica layout/posicao
|
||||||
|
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> {
|
||||||
|
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 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 {
|
||||||
|
(380.0, 480.0) // Lista expandida
|
||||||
|
};
|
||||||
|
|
||||||
|
let (x, y) = resolve_chat_window_position(app, Some(&window), width, height);
|
||||||
|
|
||||||
|
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!("Hub -> minimized={}", minimized);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,21 @@ fn set_chat_minimized(app: tauri::AppHandle, ticket_id: String, minimized: bool)
|
||||||
chat::set_chat_minimized(&app, &ticket_id, minimized)
|
chat::set_chat_minimized(&app, &ticket_id, minimized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_hub_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
chat::open_hub_window(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn close_hub_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
chat::close_hub_window(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_hub_minimized(app: tauri::AppHandle, minimized: bool) -> Result<(), String> {
|
||||||
|
chat::set_hub_minimized(&app, minimized)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Handler de Deep Link (raven://)
|
// Handler de Deep Link (raven://)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -598,7 +613,11 @@ pub fn run() {
|
||||||
open_chat_window,
|
open_chat_window,
|
||||||
close_chat_window,
|
close_chat_window,
|
||||||
minimize_chat_window,
|
minimize_chat_window,
|
||||||
set_chat_minimized
|
set_chat_minimized,
|
||||||
|
// Hub commands
|
||||||
|
open_hub_window,
|
||||||
|
close_hub_window,
|
||||||
|
set_hub_minimized
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
@ -680,7 +699,13 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||||
// Abrir janela de chat se houver sessao ativa
|
// Abrir janela de chat se houver sessao ativa
|
||||||
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
|
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
|
||||||
let sessions = chat_runtime.get_sessions();
|
let sessions = chat_runtime.get_sessions();
|
||||||
if let Some(session) = sessions.first() {
|
if sessions.len() > 1 {
|
||||||
|
// Multiplas sessoes - abrir hub
|
||||||
|
if let Err(e) = chat::open_hub_window(tray.app_handle()) {
|
||||||
|
log_error!("Falha ao abrir hub de chat: {e}");
|
||||||
|
}
|
||||||
|
} else if let Some(session) = sessions.first() {
|
||||||
|
// Uma sessao - abrir diretamente
|
||||||
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) {
|
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id, session.ticket_ref) {
|
||||||
log_error!("Falha ao abrir janela de chat: {e}");
|
log_error!("Falha ao abrir janela de chat: {e}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
212
apps/desktop/src/chat/ChatHubWidget.tsx
Normal file
212
apps/desktop/src/chat/ChatHubWidget.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
import { listen } from "@tauri-apps/api/event"
|
||||||
|
import { Loader2, MessageCircle, ChevronUp } from "lucide-react"
|
||||||
|
import { ChatSessionList } from "./ChatSessionList"
|
||||||
|
import type { ChatSession, NewMessageEvent, SessionStartedEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types"
|
||||||
|
import { getMachineStoreConfig } from "./machineStore"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub Widget - Lista todas as sessoes de chat ativas
|
||||||
|
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
|
||||||
|
*/
|
||||||
|
export function ChatHubWidget() {
|
||||||
|
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isMinimized, setIsMinimized] = useState(true) // Inicia minimizado
|
||||||
|
|
||||||
|
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
|
||||||
|
|
||||||
|
const ensureConfig = useCallback(async () => {
|
||||||
|
const cfg = configRef.current ?? (await getMachineStoreConfig())
|
||||||
|
configRef.current = cfg
|
||||||
|
return cfg
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Buscar sessoes do backend
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await ensureConfig()
|
||||||
|
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/sessions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ machineToken: cfg.token }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Falha ao buscar sessoes: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { sessions: ChatSession[] }
|
||||||
|
setSessions(data.sessions || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao carregar sessoes:", err)
|
||||||
|
setError(err instanceof Error ? err.message : "Erro desconhecido")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [ensureConfig])
|
||||||
|
|
||||||
|
// Carregar sessoes na montagem
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions()
|
||||||
|
}, [loadSessions])
|
||||||
|
|
||||||
|
// Escutar eventos de atualizacao
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisteners: (() => void)[] = []
|
||||||
|
|
||||||
|
// Quando nova sessao inicia
|
||||||
|
listen<SessionStartedEvent>("raven://chat/session-started", () => {
|
||||||
|
loadSessions()
|
||||||
|
}).then((unlisten) => unlisteners.push(unlisten))
|
||||||
|
|
||||||
|
// Quando sessao encerra
|
||||||
|
listen<SessionEndedEvent>("raven://chat/session-ended", () => {
|
||||||
|
loadSessions()
|
||||||
|
}).then((unlisten) => unlisteners.push(unlisten))
|
||||||
|
|
||||||
|
// Quando contador de nao lidos muda
|
||||||
|
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
|
||||||
|
setSessions(event.payload.sessions || [])
|
||||||
|
}).then((unlisten) => unlisteners.push(unlisten))
|
||||||
|
|
||||||
|
// Quando nova mensagem chega
|
||||||
|
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
|
||||||
|
setSessions(event.payload.sessions || [])
|
||||||
|
}).then((unlisten) => unlisteners.push(unlisten))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisteners.forEach((unlisten) => unlisten())
|
||||||
|
}
|
||||||
|
}, [loadSessions])
|
||||||
|
|
||||||
|
// Sincronizar estado minimizado com tamanho da janela
|
||||||
|
useEffect(() => {
|
||||||
|
const mountTime = Date.now()
|
||||||
|
const STABILIZATION_DELAY = 500
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
if (Date.now() - mountTime < STABILIZATION_DELAY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const h = window.innerHeight
|
||||||
|
setIsMinimized(h < 100)
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", handler)
|
||||||
|
return () => window.removeEventListener("resize", handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
|
||||||
|
try {
|
||||||
|
await invoke("open_chat_window", { ticketId, ticketRef })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao abrir janela de chat:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMinimize = async () => {
|
||||||
|
setIsMinimized(true)
|
||||||
|
try {
|
||||||
|
await invoke("set_hub_minimized", { minimized: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao minimizar hub:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExpand = async () => {
|
||||||
|
setIsMinimized(false)
|
||||||
|
try {
|
||||||
|
await invoke("set_hub_minimized", { minimized: false })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao expandir hub:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
invoke("close_hub_window")
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span className="text-sm font-medium">Carregando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erro
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoading(true)
|
||||||
|
loadSessions()
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg transition hover:bg-red-200/60"
|
||||||
|
title="Tentar novamente"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">Erro - Tentar novamente</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sem sessoes ativas - mostrar chip cinza
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||||
|
<MessageCircle className="size-4" />
|
||||||
|
<span className="text-sm font-medium">Sem chats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimizado - mostrar chip com contador
|
||||||
|
if (isMinimized) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
||||||
|
<button
|
||||||
|
onClick={handleExpand}
|
||||||
|
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
|
||||||
|
>
|
||||||
|
<MessageCircle className="size-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{sessions.length} chat{sessions.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="size-2 rounded-full bg-emerald-400" />
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
{totalUnread > 0 && (
|
||||||
|
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||||
|
{totalUnread > 9 ? "9+" : totalUnread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expandido - mostrar lista
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||||
|
<ChatSessionList
|
||||||
|
sessions={sessions}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onClose={handleClose}
|
||||||
|
onMinimize={handleMinimize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
apps/desktop/src/chat/ChatSessionItem.tsx
Normal file
82
apps/desktop/src/chat/ChatSessionItem.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { MessageCircle } from "lucide-react"
|
||||||
|
import type { ChatSession } from "./types"
|
||||||
|
|
||||||
|
type ChatSessionItemProps = {
|
||||||
|
session: ChatSession
|
||||||
|
isActive?: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: number) {
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = now - timestamp
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
|
||||||
|
if (minutes < 1) return "Agora"
|
||||||
|
if (minutes < 60) return `${minutes}min`
|
||||||
|
if (hours < 24) return `${hours}h`
|
||||||
|
if (days === 1) return "Ontem"
|
||||||
|
|
||||||
|
return new Date(timestamp).toLocaleDateString("pt-BR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) {
|
||||||
|
const hasUnread = session.unreadCount > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex w-full items-start gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
|
||||||
|
isActive ? "bg-slate-100" : ""
|
||||||
|
} ${hasUnread ? "bg-red-50/50 hover:bg-red-50" : ""}`}
|
||||||
|
>
|
||||||
|
{/* Avatar/Icone */}
|
||||||
|
<div
|
||||||
|
className={`flex size-10 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
hasUnread ? "bg-red-100 text-red-600" : "bg-slate-100 text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MessageCircle className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conteudo */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm font-semibold ${hasUnread ? "text-red-700" : "text-slate-900"}`}>
|
||||||
|
#{session.ticketRef}
|
||||||
|
</span>
|
||||||
|
{/* Indicador online - sessao ativa significa online */}
|
||||||
|
<span className="size-2 rounded-full bg-emerald-500" title="Online" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{formatTime(session.lastActivityAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-0.5 truncate text-sm text-slate-600">
|
||||||
|
{session.ticketSubject}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-400">
|
||||||
|
{session.agentName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge de nao lidos */}
|
||||||
|
{hasUnread && (
|
||||||
|
<div className="flex shrink-0 items-center justify-center">
|
||||||
|
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
||||||
|
{session.unreadCount > 9 ? "9+" : session.unreadCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
apps/desktop/src/chat/ChatSessionList.tsx
Normal file
99
apps/desktop/src/chat/ChatSessionList.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { MessageCircle, X } from "lucide-react"
|
||||||
|
import { ChatSessionItem } from "./ChatSessionItem"
|
||||||
|
import type { ChatSession } from "./types"
|
||||||
|
|
||||||
|
type ChatSessionListProps = {
|
||||||
|
sessions: ChatSession[]
|
||||||
|
onSelectSession: (ticketId: string, ticketRef: number) => void
|
||||||
|
onClose: () => void
|
||||||
|
onMinimize: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSessionList({
|
||||||
|
sessions,
|
||||||
|
onSelectSession,
|
||||||
|
onClose,
|
||||||
|
onMinimize,
|
||||||
|
}: ChatSessionListProps) {
|
||||||
|
// Ordenar: nao lidos primeiro, depois por ultima atividade (desc)
|
||||||
|
const sortedSessions = useMemo(() => {
|
||||||
|
return [...sessions].sort((a, b) => {
|
||||||
|
// Nao lidos primeiro
|
||||||
|
if (a.unreadCount > 0 && b.unreadCount === 0) return -1
|
||||||
|
if (a.unreadCount === 0 && b.unreadCount > 0) return 1
|
||||||
|
// Depois por ultima atividade (mais recente primeiro)
|
||||||
|
return b.lastActivityAt - a.lastActivityAt
|
||||||
|
})
|
||||||
|
}, [sessions])
|
||||||
|
|
||||||
|
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Header - arrastavel */}
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex items-center justify-between rounded-t-2xl border-b border-slate-200 bg-slate-50 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
|
||||||
|
<MessageCircle className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">Chats</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""}
|
||||||
|
{totalUnread > 0 && (
|
||||||
|
<span className="ml-1 text-red-500">
|
||||||
|
({totalUnread} nao lida{totalUnread !== 1 ? "s" : ""})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onMinimize}
|
||||||
|
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||||
|
title="Minimizar"
|
||||||
|
>
|
||||||
|
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||||
|
title="Fechar"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de sessoes */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-white">
|
||||||
|
{sortedSessions.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center p-4 text-center">
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-full bg-slate-100">
|
||||||
|
<MessageCircle className="size-6 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-medium text-slate-600">Nenhum chat ativo</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
Os chats aparecerao aqui quando iniciados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedSessions.map((session) => (
|
||||||
|
<ChatSessionItem
|
||||||
|
key={session.ticketId}
|
||||||
|
session={session}
|
||||||
|
onClick={() => onSelectSession(session.ticketId, session.ticketRef)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
import { ChatWidget } from "./ChatWidget"
|
import { ChatWidget } from "./ChatWidget"
|
||||||
|
import { ChatHubWidget } from "./ChatHubWidget"
|
||||||
|
|
||||||
export function ChatApp() {
|
export function ChatApp() {
|
||||||
// Obter ticketId e ticketRef da URL
|
// Obter ticketId e ticketRef da URL
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const ticketId = params.get("ticketId")
|
const ticketId = params.get("ticketId")
|
||||||
const ticketRef = params.get("ticketRef")
|
const ticketRef = params.get("ticketRef")
|
||||||
|
const isHub = params.get("hub") === "true"
|
||||||
|
|
||||||
if (!ticketId) {
|
// Modo hub - lista de todas as sessoes
|
||||||
return (
|
if (isHub || !ticketId) {
|
||||||
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
return <ChatHubWidget />
|
||||||
<p className="text-sm text-red-600">Erro: ticketId não fornecido</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modo chat - conversa de um ticket especifico
|
||||||
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ChatWidget }
|
export { ChatWidget }
|
||||||
|
export { ChatHubWidget }
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue