Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion Frontend/src/components/shared/TicketChat.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Send, User, ShieldCheck, Bot, MessageSquare, Circle, Loader2 } from 'lucide-react';
import { Send, User, ShieldCheck, Bot, MessageSquare, Circle, Loader2, Wifi, WifiOff } from 'lucide-react';
import { supabase } from "../../lib/supabaseClient";
import useAuthStore from "../../store/authStore";
import { API_CONFIG } from "../../config";
import useResilientWebSocket from "../../hooks/useResilientWebSocket";
import { buildWebSocketUrl } from "../../utils/websocket";

const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
const [messages, setMessages] = useState([]);
Expand All @@ -21,6 +24,54 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
const inputRef = useRef(null);
const channelRef = useRef(null);

// ─── Resilient WebSocket Channel (live agent communication) ───────────
const isStaffUser = profile && ['admin', 'super_admin', 'agent', 'master_admin'].includes(profile?.role);
const wsUrl = user?.id && isStaffUser
? buildWebSocketUrl(API_CONFIG.BACKEND_URL, `/ws/agents/${user.id}`)
: null;

const handleWsMessage = useCallback((data) => {
if (!data || data.type !== 'message') return;

// Ignore our own broadcasts echoing back through the hub.
if (data.from && data.from === user?.id) return;

// Live frames are merged when they target this agent directly or when
// the payload explicitly scopes a ticket id we are currently viewing.
if (data.to && data.to !== user?.id) return;
if (data.ticket_id && String(data.ticket_id) !== String(ticketId)) return;

const content = data.content;
const liveMessage = {
id: `live-${data.timestamp || Date.now()}-${data.from || 'agent'}`,
ticket_id: ticketId,
sender_id: data.from || 'live-agent',
sender_name: data.sender_name || (data.from ? 'Agent' : 'Support'),
sender_role: 'admin',
message: content,
is_internal: false,
is_live: true,
created_at: data.timestamp || new Date().toISOString()
};

setMessages((prev) => {
if (prev.find(m => m.message === content && m.sender_id === liveMessage.sender_id)) return prev;
return [...prev, liveMessage];
});
setTimeout(() => {
scrollContainerRef.current?.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}, 50);
}, [user?.id, ticketId]);

const { status: wsStatus, send: wsSend } = useResilientWebSocket({
url: wsUrl,
enabled: Boolean(wsUrl && ticketId),
onMessage: handleWsMessage
});

// ─── Fetch Messages ──────────────────────────────────────────────────
const fetchMessages = async () => {
if (!ticketId) return;
Expand Down Expand Up @@ -208,6 +259,16 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
}]);
if (error) throw error;
}

// Fan the message out to online agents over the resilient socket.
wsSend({
type: 'broadcast',
ticket_id: ticketId,
content,
sender_id: user.id,
sender_name: profile?.full_name || user.email,
timestamp: new Date().toISOString()
});
} catch (err) {
console.error("Error sending message:", err);
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
Expand Down Expand Up @@ -255,6 +316,23 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
</div>

<div className="flex items-center gap-4">
{isStaff && (
<div
title={`WebSocket channel: ${wsStatus}`}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${
wsStatus === 'connected'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: wsStatus === 'reconnecting'
? 'bg-amber-50 text-amber-700 border-amber-200 animate-pulse'
: wsStatus === 'connecting'
? 'bg-slate-50 text-slate-500 border-slate-200'
: 'bg-slate-50 text-slate-400 border-slate-200'
}`}
>
{wsStatus === 'connected' ? <Wifi size={10} /> : wsStatus === 'reconnecting' ? <Loader2 size={10} className="animate-spin" /> : <WifiOff size={10} />}
{wsStatus === 'connected' ? 'Live' : wsStatus === 'reconnecting' ? 'Reconnecting' : wsStatus === 'connecting' ? 'Connecting' : 'Offline'}
</div>
)}
{isStaff && (
<div style={{ display: 'flex', alignItems: 'center', background: 'transparent', gap: '4px' }}>
<button
Expand Down
151 changes: 151 additions & 0 deletions Frontend/src/hooks/useResilientWebSocket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useEffect, useRef, useState, useCallback } from 'react';

const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const RECONNECT_BACKOFF_EXP = 6;
const DEFAULT_HEARTBEAT_MS = 25000;

/**
* useResilientWebSocket — a connection that survives network blips.
*
* - Reconnects automatically with exponential backoff (1s -> 30s cap).
* - Sends a JSON `{"type":"ping"}` heartbeat so idle channels stay alive.
* - Exposes a `status` of 'idle' | 'connecting' | 'connected' | 'reconnecting'.
* - `send(payload)` returns true only when the socket is actually open.
*
* @param {object} options
* @param {string|null} options.url ws(s) URL; null disables the connection.
* @param {boolean} options.enabled When false the connection is torn down.
* @param {function} options.onMessage Invoked with parsed frames.
* @param {number} options.heartbeatMs Heartbeat interval in ms.
*/
export default function useResilientWebSocket({
url,
enabled = true,
onMessage,
heartbeatMs = DEFAULT_HEARTBEAT_MS,
}) {
const [status, setStatus] = useState('idle');
const wsRef = useRef(null);
const attemptsRef = useRef(0);
const reconnectTimerRef = useRef(null);
const heartbeatRef = useRef(null);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;

const send = useCallback((payload) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(typeof payload === 'string' ? payload : JSON.stringify(payload));
return true;
}
return false;
}, []);

useEffect(() => {
if (!enabled || !url) {
setStatus('idle');
return undefined;
}

let cancelled = false;

const backoff = () =>
Math.min(
RECONNECT_BASE_MS * 2 ** Math.min(attemptsRef.current, RECONNECT_BACKOFF_EXP),
RECONNECT_MAX_MS
);

const clearTimers = () => {
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
reconnectTimerRef.current = null;
heartbeatRef.current = null;
};

const connect = () => {
if (cancelled) return;

let ws;
try {
ws = new WebSocket(url);
} catch {
setStatus('reconnecting');
scheduleReconnect();
return;
}

wsRef.current = ws;
setStatus('connecting');

ws.onopen = () => {
attemptsRef.current = 0;
setStatus('connected');
heartbeatRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping' }));
} catch {
/* channel died mid-interval; onclose handles it */
}
}
}, heartbeatMs);
};

ws.onmessage = (event) => {
let data = event.data;
try {
data = JSON.parse(event.data);
} catch {
/* keep raw string */
}
if (data && data.type === 'pong') return;
onMessageRef.current?.(data, event);
};

ws.onclose = () => {
clearTimers();
wsRef.current = null;
if (cancelled) {
setStatus('idle');
return;
}
attemptsRef.current += 1;
setStatus('reconnecting');
scheduleReconnect();
};

ws.onerror = () => {
try {
ws.close();
} catch {
/* onclose will still fire */
}
};
};

const scheduleReconnect = () => {
if (cancelled) return;
reconnectTimerRef.current = setTimeout(connect, backoff());
};

connect();

return () => {
cancelled = true;
clearTimers();
const ws = wsRef.current;
if (ws) {
ws.onclose = null;
try {
ws.close();
} catch {
/* already closed */
}
}
wsRef.current = null;
};
}, [url, enabled, heartbeatMs]);

return { status, send };
}
22 changes: 22 additions & 0 deletions Frontend/src/utils/websocket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* WebSocket URL helpers for the live support channels.
*/

/**
* Converts an http(s) backend base URL into a ws(s) origin and appends `path`.
* Returns null when the base URL is missing or malformed so callers can
* degrade gracefully (e.g. keep using Supabase realtime).
*/
export const buildWebSocketUrl = (baseUrl, path) => {
const normalized = String(baseUrl || '').trim().replace(/\/+$/, '');
if (!normalized) return null;
let url;
try {
url = new URL(normalized);
} catch {
return null;
}
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const origin = url.toString().replace(/\/+$/, '');
return `${origin}${String(path || '').startsWith('/') ? '' : '/'}${path || ''}`;
};
Loading