Corrige comportamentos do chat e melhora UX
- Corrige contador de mensagens resetando sozinho (web) - Adiciona verificacao de visibilidade antes de marcar como lido - Verifica se aba esta ativa antes de marcar como lido - Adiciona sincronizacao de estado do chat entre abas (web) - Usa BroadcastChannel para sincronizar aberto/fechado/minimizado - Persiste estado no localStorage - Corrige chat minimizando sozinho no desktop (Rust) - Verifica se chat esta expandido antes de minimizar - Mantem chat aberto quando usuario esta usando - Melhora encerramento automatico de sessoes de chat - Muda criterio de inatividade de chat para maquina offline - Sessao permanece ativa enquanto Raven estiver aberto - Encerra apenas quando maquina fica offline por 5+ min - Corrige tabela de tickets em /devices - Adiciona acentuacao correta (Ultima atualizacao, Responsavel) - Torna linha inteira clicavel para abrir ticket - Ajusta sidebar - Menu Tickets agora expande ao clicar (igual Cadastros) 🤖 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
2682b6e8ac
commit
f4880f32d2
7 changed files with 219 additions and 37 deletions
|
|
@ -25,7 +25,11 @@
|
||||||
"Bash(\"\"\" OWNER TO renan; FROM pg_tables WHERE schemaname = public;\"\" | docker exec -i c95ebc27eb82 psql -U sistema -d strapi_blog\")",
|
"Bash(\"\"\" OWNER TO renan; FROM pg_tables WHERE schemaname = public;\"\" | docker exec -i c95ebc27eb82 psql -U sistema -d strapi_blog\")",
|
||||||
"Bash(sequence_name)",
|
"Bash(sequence_name)",
|
||||||
"Bash(\"\"\" OWNER TO renan; FROM information_schema.sequences WHERE sequence_schema = public;\"\" | docker exec -i c95ebc27eb82 psql -U sistema -d strapi_blog\")",
|
"Bash(\"\"\" OWNER TO renan; FROM information_schema.sequences WHERE sequence_schema = public;\"\" | docker exec -i c95ebc27eb82 psql -U sistema -d strapi_blog\")",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(cargo check:*)",
|
||||||
|
"Bash(bun run:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -674,16 +674,25 @@ async fn process_chat_update(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mostrar janela de chat minimizada (menos intrusivo que abrir completo)
|
// Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra)
|
||||||
// A janela ja abre minimizada por padrao (start_minimized=true)
|
|
||||||
if let Some(session) = current_sessions.first() {
|
if let Some(session) = current_sessions.first() {
|
||||||
let label = format!("chat-{}", session.ticket_id);
|
let label = format!("chat-{}", session.ticket_id);
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
// Janela ja existe - apenas mostrar e garantir que esta minimizada
|
// 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();
|
let _ = window.show();
|
||||||
let _ = set_chat_minimized(app, &session.ticket_id, true);
|
// 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 (sem necessidade de chamar set_chat_minimized depois)
|
// Criar nova janela ja minimizada (menos intrusivo)
|
||||||
let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref);
|
let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -753,43 +753,78 @@ export const getTicketChatHistory = query({
|
||||||
// ENCERRAMENTO AUTOMATICO POR INATIVIDADE
|
// ENCERRAMENTO AUTOMATICO POR INATIVIDADE
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Timeout de inatividade: 5 minutos
|
// Timeout de maquina offline: 5 minutos sem heartbeat
|
||||||
const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000
|
const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
// Mutation interna para encerrar sessões inativas (chamada pelo cron)
|
// Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron)
|
||||||
// Otimizada com paginação para evitar timeout
|
// Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat
|
||||||
|
// Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens
|
||||||
export const autoEndInactiveSessions = mutation({
|
export const autoEndInactiveSessions = mutation({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
// Log obrigatorio para evitar shape_inference errors com logLines vazios
|
console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)")
|
||||||
console.log("cron: autoEndInactiveSessions iniciado")
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const cutoffTime = now - INACTIVITY_TIMEOUT_MS
|
const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS
|
||||||
|
|
||||||
// Limitar a 50 sessões por execução para evitar timeout do cron (30s)
|
// Limitar a 50 sessões por execução para evitar timeout do cron (30s)
|
||||||
const maxSessionsPerRun = 50
|
const maxSessionsPerRun = 50
|
||||||
|
|
||||||
// Buscar sessões ativas com inatividade > 5 minutos (usando índice otimizado)
|
// Buscar todas as sessões ativas
|
||||||
const inactiveSessions = await ctx.db
|
const activeSessions = await ctx.db
|
||||||
.query("liveChatSessions")
|
.query("liveChatSessions")
|
||||||
.withIndex("by_status_lastActivity", (q) =>
|
.withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE"))
|
||||||
q.eq("status", "ACTIVE").lt("lastActivityAt", cutoffTime)
|
|
||||||
)
|
|
||||||
.take(maxSessionsPerRun)
|
.take(maxSessionsPerRun)
|
||||||
|
|
||||||
let endedCount = 0
|
let endedCount = 0
|
||||||
|
let checkedCount = 0
|
||||||
|
|
||||||
for (const session of inactiveSessions) {
|
for (const session of activeSessions) {
|
||||||
// Encerrar a sessão
|
checkedCount++
|
||||||
|
|
||||||
|
// Buscar o ticket para obter a máquina
|
||||||
|
const ticket = await ctx.db.get(session.ticketId)
|
||||||
|
if (!ticket || !ticket.machineId) {
|
||||||
|
// Ticket sem máquina - encerrar sessão órfã
|
||||||
|
await ctx.db.patch(session._id, {
|
||||||
|
status: "ENDED",
|
||||||
|
endedAt: now,
|
||||||
|
})
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: session.ticketId,
|
||||||
|
type: "LIVE_CHAT_ENDED",
|
||||||
|
payload: {
|
||||||
|
sessionId: session._id,
|
||||||
|
agentId: session.agentId,
|
||||||
|
agentName: session.agentSnapshot?.name ?? "Sistema",
|
||||||
|
durationMs: now - session.startedAt,
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
endedAt: now,
|
||||||
|
autoEnded: true,
|
||||||
|
reason: "ticket_sem_maquina",
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
endedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar heartbeat da máquina
|
||||||
|
const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId)
|
||||||
|
const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff
|
||||||
|
|
||||||
|
// Se máquina está online, manter sessão ativa
|
||||||
|
if (machineIsOnline) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Máquina está offline - encerrar sessão
|
||||||
await ctx.db.patch(session._id, {
|
await ctx.db.patch(session._id, {
|
||||||
status: "ENDED",
|
status: "ENDED",
|
||||||
endedAt: now,
|
endedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calcular duração da sessão
|
|
||||||
const durationMs = now - session.startedAt
|
const durationMs = now - session.startedAt
|
||||||
|
|
||||||
// Registrar evento na timeline
|
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId: session.ticketId,
|
ticketId: session.ticketId,
|
||||||
type: "LIVE_CHAT_ENDED",
|
type: "LIVE_CHAT_ENDED",
|
||||||
|
|
@ -800,8 +835,8 @@ export const autoEndInactiveSessions = mutation({
|
||||||
durationMs,
|
durationMs,
|
||||||
startedAt: session.startedAt,
|
startedAt: session.startedAt,
|
||||||
endedAt: now,
|
endedAt: now,
|
||||||
autoEnded: true, // Flag para indicar encerramento automático
|
autoEnded: true,
|
||||||
reason: "inatividade",
|
reason: "maquina_offline",
|
||||||
},
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|
@ -809,7 +844,8 @@ export const autoEndInactiveSessions = mutation({
|
||||||
endedCount++
|
endedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
return { endedCount, hasMore: inactiveSessions.length === maxSessionsPerRun }
|
console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`)
|
||||||
|
return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { usePaginatedQuery, useQuery } from "convex/react"
|
import { usePaginatedQuery, useQuery } from "convex/react"
|
||||||
import { format, formatDistanceToNowStrict } from "date-fns"
|
import { format, formatDistanceToNowStrict } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
@ -145,6 +146,7 @@ function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
|
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
|
||||||
|
const router = useRouter()
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
|
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
|
||||||
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
|
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
|
||||||
const [requesterFilter, setRequesterFilter] = useState<string>("ALL")
|
const [requesterFilter, setRequesterFilter] = useState<string>("ALL")
|
||||||
|
|
@ -373,10 +375,10 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
|
||||||
Prioridade
|
Prioridade
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
Ultima atualizacao
|
Última atualização
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
Responsavel
|
Responsável
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -387,7 +389,11 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
|
||||||
const updatedLabel = formatRelativeTime(ticket.updatedAt)
|
const updatedLabel = formatRelativeTime(ticket.updatedAt)
|
||||||
const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt)
|
const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt)
|
||||||
return (
|
return (
|
||||||
<TableRow key={ticket.id} className="border-b border-slate-100 hover:bg-slate-50/70">
|
<TableRow
|
||||||
|
key={ticket.id}
|
||||||
|
className="cursor-pointer border-b border-slate-100 hover:bg-slate-50/70"
|
||||||
|
onClick={() => router.push(`/tickets/${ticket.id}`)}
|
||||||
|
>
|
||||||
<TableCell className="px-4 py-3 text-center align-middle">
|
<TableCell className="px-4 py-3 text-center align-middle">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -269,8 +269,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole))
|
const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole))
|
||||||
const isExpanded = expanded.has(item.title)
|
const isExpanded = expanded.has(item.title)
|
||||||
const isChildActive = childItems.some((child) => isActive(child))
|
const isChildActive = childItems.some((child) => isActive(child))
|
||||||
const parentActive = item.title === "Tickets" ? isActive(item) || isChildActive : isChildActive
|
const parentActive = isChildActive
|
||||||
const isToggleOnly = item.title !== "Tickets"
|
const isToggleOnly = true // Todos os menus com filhos expandem ao clicar, nao navegam
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.title}>
|
<React.Fragment key={item.title}>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ import {
|
||||||
const MAX_MESSAGE_LENGTH = 4000
|
const MAX_MESSAGE_LENGTH = 4000
|
||||||
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
|
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
const MAX_ATTACHMENTS = 5
|
const MAX_ATTACHMENTS = 5
|
||||||
|
const CHAT_WIDGET_CHANNEL = "chat-widget-sync"
|
||||||
|
const STORAGE_KEY = "chat-widget-state"
|
||||||
|
|
||||||
|
type ChatWidgetState = {
|
||||||
|
isOpen: boolean
|
||||||
|
isMinimized: boolean
|
||||||
|
activeTicketId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: number) {
|
function formatTime(timestamp: number) {
|
||||||
return new Date(timestamp).toLocaleTimeString("pt-BR", {
|
return new Date(timestamp).toLocaleTimeString("pt-BR", {
|
||||||
|
|
@ -238,10 +246,42 @@ export function ChatWidget() {
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId } = useAuth()
|
||||||
const viewerId = convexUserId ?? null
|
const viewerId = convexUserId ?? null
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
// Inicializar estado a partir do localStorage (para persistir entre reloads)
|
||||||
const [isMinimized, setIsMinimized] = useState(false)
|
const [isOpen, setIsOpen] = useState(() => {
|
||||||
const [activeTicketId, setActiveTicketId] = useState<string | null>(null)
|
if (typeof window === "undefined") return false
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved) {
|
||||||
|
const state = JSON.parse(saved) as ChatWidgetState
|
||||||
|
return state.isOpen
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
const [isMinimized, setIsMinimized] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved) {
|
||||||
|
const state = JSON.parse(saved) as ChatWidgetState
|
||||||
|
return state.isMinimized
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
const [activeTicketId, setActiveTicketId] = useState<string | null>(() => {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved) {
|
||||||
|
const state = JSON.parse(saved) as ChatWidgetState
|
||||||
|
return state.activeTicketId
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null
|
||||||
|
})
|
||||||
const [draft, setDraft] = useState("")
|
const [draft, setDraft] = useState("")
|
||||||
|
const broadcastChannelRef = useRef<BroadcastChannel | null>(null)
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const [isEndingChat, setIsEndingChat] = useState(false)
|
const [isEndingChat, setIsEndingChat] = useState(false)
|
||||||
const [attachments, setAttachments] = useState<UploadedFile[]>([])
|
const [attachments, setAttachments] = useState<UploadedFile[]>([])
|
||||||
|
|
@ -279,6 +319,51 @@ export function ChatWidget() {
|
||||||
const machineOnline = liveChat?.machineOnline ?? false
|
const machineOnline = liveChat?.machineOnline ?? false
|
||||||
const machineHostname = liveChat?.machineHostname
|
const machineHostname = liveChat?.machineHostname
|
||||||
|
|
||||||
|
// Sincronizar estado entre abas usando BroadcastChannel
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
// Criar canal de broadcast
|
||||||
|
const channel = new BroadcastChannel(CHAT_WIDGET_CHANNEL)
|
||||||
|
broadcastChannelRef.current = channel
|
||||||
|
|
||||||
|
// Ouvir mensagens de outras abas
|
||||||
|
channel.onmessage = (event: MessageEvent<ChatWidgetState>) => {
|
||||||
|
const state = event.data
|
||||||
|
setIsOpen(state.isOpen)
|
||||||
|
setIsMinimized(state.isMinimized)
|
||||||
|
if (state.activeTicketId) {
|
||||||
|
setActiveTicketId(state.activeTicketId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
channel.close()
|
||||||
|
broadcastChannelRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Salvar estado no localStorage e broadcast para outras abas quando muda
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
const state: ChatWidgetState = {
|
||||||
|
isOpen,
|
||||||
|
isMinimized,
|
||||||
|
activeTicketId,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvar no localStorage para persistir entre reloads
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Broadcast para outras abas
|
||||||
|
if (broadcastChannelRef.current) {
|
||||||
|
broadcastChannelRef.current.postMessage(state)
|
||||||
|
}
|
||||||
|
}, [isOpen, isMinimized, activeTicketId])
|
||||||
|
|
||||||
// Auto-selecionar primeira sessão se nenhuma selecionada
|
// Auto-selecionar primeira sessão se nenhuma selecionada
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTicketId && activeSessions && activeSessions.length > 0) {
|
if (!activeTicketId && activeSessions && activeSessions.length > 0) {
|
||||||
|
|
@ -316,8 +401,10 @@ export function ChatWidget() {
|
||||||
// Marcar mensagens como lidas ao abrir/mostrar chat
|
// Marcar mensagens como lidas ao abrir/mostrar chat
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!viewerId || !chat || !activeTicketId) return
|
if (!viewerId || !chat || !activeTicketId) return
|
||||||
// Só marca quando o widget está aberto e visível
|
// Só marca quando o widget está aberto, expandido e a aba está ativa
|
||||||
if (!isOpen || isMinimized) return
|
if (!isOpen || isMinimized) return
|
||||||
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") return
|
||||||
|
|
||||||
const unreadIds = chat.messages
|
const unreadIds = chat.messages
|
||||||
?.filter((msg) => !msg.readBy?.some((r) => r.userId === viewerId))
|
?.filter((msg) => !msg.readBy?.some((r) => r.userId === viewerId))
|
||||||
.map((msg) => msg.id) ?? []
|
.map((msg) => msg.id) ?? []
|
||||||
|
|
@ -492,7 +579,15 @@ export function ChatWidget() {
|
||||||
|
|
||||||
// Nao mostrar se nao logado ou sem sessoes
|
// Nao mostrar se nao logado ou sem sessoes
|
||||||
if (!viewerId) return null
|
if (!viewerId) return null
|
||||||
if (!activeSessions || activeSessions.length === 0) return null
|
if (!activeSessions || activeSessions.length === 0) {
|
||||||
|
// Limpar estado salvo quando nao ha sessoes
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const activeSession = activeSessions.find((s) => s.ticketId === activeTicketId)
|
const activeSession = activeSessions.find((s) => s.ticketId === activeTicketId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,9 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [draft, setDraft] = useState("")
|
const [draft, setDraft] = useState("")
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const [isStartingChat, setIsStartingChat] = useState(false)
|
const [isStartingChat, setIsStartingChat] = useState(false)
|
||||||
const [isEndingChat, setIsEndingChat] = useState(false)
|
const [isEndingChat, setIsEndingChat] = useState(false)
|
||||||
|
|
@ -93,8 +95,38 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
const liveChat = chat?.liveChat
|
const liveChat = chat?.liveChat
|
||||||
const hasActiveSession = Boolean(liveChat?.activeSession)
|
const hasActiveSession = Boolean(liveChat?.activeSession)
|
||||||
|
|
||||||
|
// Detectar visibilidade do componente na viewport
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
setIsVisible(entry.isIntersecting)
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
)
|
||||||
|
observer.observe(container)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Detectar se a aba esta ativa
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
setIsVisible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
return () => document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Marcar como lido apenas quando o componente esta visivel E a aba esta ativa
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!viewerId || !chat || !Array.isArray(chat.messages) || chat.messages.length === 0) return
|
if (!viewerId || !chat || !Array.isArray(chat.messages) || chat.messages.length === 0) return
|
||||||
|
// Verificar se o componente esta visivel e a aba esta ativa
|
||||||
|
if (!isVisible || document.visibilityState === "hidden") return
|
||||||
|
|
||||||
const unreadIds = chat.messages
|
const unreadIds = chat.messages
|
||||||
.filter((message) => {
|
.filter((message) => {
|
||||||
const alreadyRead = (message.readBy ?? []).some((entry) => entry.userId === viewerId)
|
const alreadyRead = (message.readBy ?? []).some((entry) => entry.userId === viewerId)
|
||||||
|
|
@ -109,7 +141,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("Failed to mark chat messages as read", error)
|
console.error("Failed to mark chat messages as read", error)
|
||||||
})
|
})
|
||||||
}, [markChatRead, chat, ticketId, viewerId])
|
}, [markChatRead, chat, ticketId, viewerId, isVisible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current) {
|
if (messagesEndRef.current) {
|
||||||
|
|
@ -230,7 +262,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col overflow-hidden border-slate-200">
|
<Card ref={containerRef} className="flex flex-col overflow-hidden border-slate-200">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-slate-100 bg-slate-50 px-4 pr-3 py-3">
|
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-slate-100 bg-slate-50 px-4 pr-3 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue