Files
clientsflow/frontend/app/composables/useCrmRealtime.ts
Ruslan Bakiev 643d8d02ba feat: granular WebSocket message.new events
- WebSocket now detects new ContactMessages and broadcasts
  message.new events with contactId, text, channel, direction
- Frontend handles message.new: refreshes timeline for open chat,
  refreshes contacts for sidebar preview update
- dashboard.changed still fires for non-message changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:04:55 +07:00

144 lines
4.1 KiB
TypeScript

import { ref } from "vue";
export type RealtimeNewMessage = {
contactId: string;
contactName: string;
text: string;
channel: string;
direction: string;
at: string;
};
export function useCrmRealtime(opts: {
isAuthenticated: () => boolean;
onDashboardChanged: () => Promise<void>;
onNewMessage?: (msg: RealtimeNewMessage) => void;
}) {
const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle");
let crmRealtimeSocket: WebSocket | null = null;
let crmRealtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshInFlight = false;
let crmRealtimeReconnectAttempt = 0;
function clearCrmRealtimeReconnectTimer() {
if (!crmRealtimeReconnectTimer) return;
clearTimeout(crmRealtimeReconnectTimer);
crmRealtimeReconnectTimer = null;
}
function clearCrmRealtimeRefreshTimer() {
if (!crmRealtimeRefreshTimer) return;
clearTimeout(crmRealtimeRefreshTimer);
crmRealtimeRefreshTimer = null;
}
async function runCrmRealtimeRefresh() {
if (!opts.isAuthenticated() || crmRealtimeRefreshInFlight) return;
crmRealtimeRefreshInFlight = true;
try {
await opts.onDashboardChanged();
} catch {
// ignore transient realtime refresh errors
} finally {
crmRealtimeRefreshInFlight = false;
}
}
function scheduleCrmRealtimeRefresh(delayMs = 250) {
clearCrmRealtimeRefreshTimer();
crmRealtimeRefreshTimer = setTimeout(() => {
crmRealtimeRefreshTimer = null;
void runCrmRealtimeRefresh();
}, delayMs);
}
function scheduleCrmRealtimeReconnect() {
clearCrmRealtimeReconnectTimer();
const attempt = Math.min(crmRealtimeReconnectAttempt + 1, 8);
crmRealtimeReconnectAttempt = attempt;
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 15000);
crmRealtimeReconnectTimer = setTimeout(() => {
crmRealtimeReconnectTimer = null;
startCrmRealtime();
}, delayMs);
}
function stopCrmRealtime() {
clearCrmRealtimeReconnectTimer();
clearCrmRealtimeRefreshTimer();
if (crmRealtimeSocket) {
const socket = crmRealtimeSocket;
crmRealtimeSocket = null;
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
try {
socket.close(1000, "client stop");
} catch {
// ignore socket close errors
}
}
crmRealtimeState.value = "idle";
}
function startCrmRealtime() {
if (process.server || !opts.isAuthenticated()) return;
if (crmRealtimeSocket) {
const state = crmRealtimeSocket.readyState;
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return;
}
clearCrmRealtimeReconnectTimer();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws/crm-updates`;
const socket = new WebSocket(url);
crmRealtimeSocket = socket;
crmRealtimeState.value = "connecting";
socket.onopen = () => {
crmRealtimeState.value = "open";
crmRealtimeReconnectAttempt = 0;
};
socket.onmessage = (event) => {
const raw = typeof event.data === "string" ? event.data : "";
if (!raw) return;
try {
const payload = JSON.parse(raw) as { type?: string; [key: string]: any };
if (payload.type === "dashboard.changed") {
scheduleCrmRealtimeRefresh();
}
if (payload.type === "message.new" && opts.onNewMessage) {
opts.onNewMessage(payload as unknown as RealtimeNewMessage);
}
} catch {
// ignore malformed realtime payloads
}
};
socket.onerror = () => {
crmRealtimeState.value = "error";
};
socket.onclose = () => {
const wasActive = crmRealtimeSocket === socket;
if (wasActive) {
crmRealtimeSocket = null;
}
if (!opts.isAuthenticated()) {
crmRealtimeState.value = "idle";
return;
}
crmRealtimeState.value = "error";
scheduleCrmRealtimeReconnect();
};
}
return { crmRealtimeState, startCrmRealtime, stopCrmRealtime };
}