ajaxter-chat 3.0.16 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/components/BlockList/index.d.ts +1 -0
  2. package/dist/components/BlockList/index.d.ts.map +1 -0
  3. package/dist/components/BlockList/index.js +55 -28
  4. package/dist/components/BlockList/index.js.map +1 -0
  5. package/dist/components/CallScreen/index.d.ts +3 -0
  6. package/dist/components/CallScreen/index.d.ts.map +1 -0
  7. package/dist/components/CallScreen/index.js +107 -29
  8. package/dist/components/CallScreen/index.js.map +1 -0
  9. package/dist/components/ChatScreen/index.d.ts +1 -0
  10. package/dist/components/ChatScreen/index.d.ts.map +1 -0
  11. package/dist/components/ChatScreen/index.js +493 -294
  12. package/dist/components/ChatScreen/index.js.map +1 -0
  13. package/dist/components/ChatWidget.d.ts +1 -0
  14. package/dist/components/ChatWidget.d.ts.map +1 -0
  15. package/dist/components/ChatWidget.js +359 -250
  16. package/dist/components/ChatWidget.js.map +1 -0
  17. package/dist/components/EmojiPicker/index.d.ts +1 -0
  18. package/dist/components/EmojiPicker/index.d.ts.map +1 -0
  19. package/dist/components/EmojiPicker/index.js +19 -7
  20. package/dist/components/EmojiPicker/index.js.map +1 -0
  21. package/dist/components/ErrorBoundary/index.d.ts +20 -0
  22. package/dist/components/ErrorBoundary/index.d.ts.map +1 -0
  23. package/dist/components/ErrorBoundary/index.js +76 -0
  24. package/dist/components/ErrorBoundary/index.js.map +1 -0
  25. package/dist/components/HomeScreen/index.d.ts +1 -0
  26. package/dist/components/HomeScreen/index.d.ts.map +1 -0
  27. package/dist/components/HomeScreen/index.js +236 -158
  28. package/dist/components/HomeScreen/index.js.map +1 -0
  29. package/dist/components/MaintenanceView/index.d.ts +1 -0
  30. package/dist/components/MaintenanceView/index.d.ts.map +1 -0
  31. package/dist/components/MaintenanceView/index.js +28 -12
  32. package/dist/components/MaintenanceView/index.js.map +1 -0
  33. package/dist/components/MiniCallBar/index.d.ts +15 -0
  34. package/dist/components/MiniCallBar/index.d.ts.map +1 -0
  35. package/dist/components/MiniCallBar/index.js +116 -0
  36. package/dist/components/MiniCallBar/index.js.map +1 -0
  37. package/dist/components/PermissionsGateScreen/index.d.ts +1 -0
  38. package/dist/components/PermissionsGateScreen/index.d.ts.map +1 -0
  39. package/dist/components/PermissionsGateScreen/index.js +82 -28
  40. package/dist/components/PermissionsGateScreen/index.js.map +1 -0
  41. package/dist/components/RecentChatsScreen/index.d.ts +1 -0
  42. package/dist/components/RecentChatsScreen/index.d.ts.map +1 -0
  43. package/dist/components/RecentChatsScreen/index.js +79 -19
  44. package/dist/components/RecentChatsScreen/index.js.map +1 -0
  45. package/dist/components/SlideNavMenu.d.ts +1 -0
  46. package/dist/components/SlideNavMenu.d.ts.map +1 -0
  47. package/dist/components/SlideNavMenu.js +82 -63
  48. package/dist/components/SlideNavMenu.js.map +1 -0
  49. package/dist/components/Tabs/BottomTabs.d.ts +1 -0
  50. package/dist/components/Tabs/BottomTabs.d.ts.map +1 -0
  51. package/dist/components/Tabs/BottomTabs.js +34 -19
  52. package/dist/components/Tabs/BottomTabs.js.map +1 -0
  53. package/dist/components/TicketDetailScreen/index.d.ts +1 -0
  54. package/dist/components/TicketDetailScreen/index.d.ts.map +1 -0
  55. package/dist/components/TicketDetailScreen/index.js +66 -27
  56. package/dist/components/TicketDetailScreen/index.js.map +1 -0
  57. package/dist/components/TicketFormScreen/index.d.ts +1 -0
  58. package/dist/components/TicketFormScreen/index.d.ts.map +1 -0
  59. package/dist/components/TicketFormScreen/index.js +99 -49
  60. package/dist/components/TicketFormScreen/index.js.map +1 -0
  61. package/dist/components/TicketScreen/index.d.ts +1 -0
  62. package/dist/components/TicketScreen/index.d.ts.map +1 -0
  63. package/dist/components/TicketScreen/index.js +95 -26
  64. package/dist/components/TicketScreen/index.js.map +1 -0
  65. package/dist/components/UserListScreen/index.d.ts +1 -0
  66. package/dist/components/UserListScreen/index.d.ts.map +1 -0
  67. package/dist/components/UserListScreen/index.js +127 -53
  68. package/dist/components/UserListScreen/index.js.map +1 -0
  69. package/dist/components/ViewerBlockedScreen/index.d.ts +1 -0
  70. package/dist/components/ViewerBlockedScreen/index.d.ts.map +1 -0
  71. package/dist/components/ViewerBlockedScreen/index.js +113 -61
  72. package/dist/components/ViewerBlockedScreen/index.js.map +1 -0
  73. package/dist/config/index.d.ts +1 -0
  74. package/dist/config/index.d.ts.map +1 -0
  75. package/dist/config/index.js +7 -2
  76. package/dist/config/index.js.map +1 -0
  77. package/dist/hooks/useChat.d.ts +9 -1
  78. package/dist/hooks/useChat.d.ts.map +1 -0
  79. package/dist/hooks/useChat.js +60 -18
  80. package/dist/hooks/useChat.js.map +1 -0
  81. package/dist/hooks/useRemoteConfig.d.ts +1 -0
  82. package/dist/hooks/useRemoteConfig.d.ts.map +1 -0
  83. package/dist/hooks/useRemoteConfig.js +12 -8
  84. package/dist/hooks/useRemoteConfig.js.map +1 -0
  85. package/dist/hooks/useSocket.d.ts +40 -0
  86. package/dist/hooks/useSocket.d.ts.map +1 -0
  87. package/dist/hooks/useSocket.js +190 -0
  88. package/dist/hooks/useSocket.js.map +1 -0
  89. package/dist/hooks/useWebRTC.d.ts +10 -2
  90. package/dist/hooks/useWebRTC.d.ts.map +1 -0
  91. package/dist/hooks/useWebRTC.js +101 -69
  92. package/dist/hooks/useWebRTC.js.map +1 -0
  93. package/dist/index.d.ts +6 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +67 -21
  96. package/dist/index.js.map +1 -0
  97. package/dist/types/index.d.ts +1 -0
  98. package/dist/types/index.d.ts.map +1 -0
  99. package/dist/types/index.js +3 -1
  100. package/dist/types/index.js.map +1 -0
  101. package/dist/utils/chat.d.ts +1 -0
  102. package/dist/utils/chat.d.ts.map +1 -0
  103. package/dist/utils/chat.js +17 -7
  104. package/dist/utils/chat.js.map +1 -0
  105. package/dist/utils/fileName.d.ts +1 -0
  106. package/dist/utils/fileName.d.ts.map +1 -0
  107. package/dist/utils/fileName.js +5 -1
  108. package/dist/utils/fileName.js.map +1 -0
  109. package/dist/utils/messageSound.d.ts +1 -0
  110. package/dist/utils/messageSound.d.ts.map +1 -0
  111. package/dist/utils/messageSound.js +9 -3
  112. package/dist/utils/messageSound.js.map +1 -0
  113. package/dist/utils/presenceStatus.d.ts +1 -0
  114. package/dist/utils/presenceStatus.d.ts.map +1 -0
  115. package/dist/utils/presenceStatus.js +11 -4
  116. package/dist/utils/presenceStatus.js.map +1 -0
  117. package/dist/utils/privacyConsent.d.ts +1 -0
  118. package/dist/utils/privacyConsent.d.ts.map +1 -0
  119. package/dist/utils/privacyConsent.js +9 -3
  120. package/dist/utils/privacyConsent.js.map +1 -0
  121. package/dist/utils/reenableRequest.d.ts +1 -0
  122. package/dist/utils/reenableRequest.d.ts.map +1 -0
  123. package/dist/utils/reenableRequest.js +5 -1
  124. package/dist/utils/reenableRequest.js.map +1 -0
  125. package/dist/utils/theme.d.ts +1 -0
  126. package/dist/utils/theme.d.ts.map +1 -0
  127. package/dist/utils/theme.js +10 -4
  128. package/dist/utils/theme.js.map +1 -0
  129. package/dist/utils/widgetPermissions.d.ts +1 -0
  130. package/dist/utils/widgetPermissions.d.ts.map +1 -0
  131. package/dist/utils/widgetPermissions.js +13 -5
  132. package/dist/utils/widgetPermissions.js.map +1 -0
  133. package/dist/utils/widgetSession.d.ts +1 -0
  134. package/dist/utils/widgetSession.d.ts.map +1 -0
  135. package/dist/utils/widgetSession.js +9 -3
  136. package/dist/utils/widgetSession.js.map +1 -0
  137. package/package.json +3 -3
  138. package/src/components/CallScreen/index.tsx +23 -1
  139. package/src/components/ChatScreen/index.tsx +2 -1
  140. package/src/components/ChatWidget.tsx +314 -263
  141. package/src/components/ErrorBoundary/index.tsx +62 -0
  142. package/src/components/HomeScreen/index.tsx +0 -3
  143. package/src/components/MiniCallBar/index.tsx +150 -0
  144. package/src/hooks/useChat.ts +59 -12
  145. package/src/hooks/useSocket.ts +228 -0
  146. package/src/hooks/useWebRTC.ts +99 -64
  147. package/src/index.ts +7 -2
@@ -0,0 +1,62 @@
1
+ import React, { Component, ErrorInfo, ReactNode } from 'react';
2
+
3
+ interface Props {
4
+ children: ReactNode;
5
+ fallback?: ReactNode;
6
+ primaryColor?: string;
7
+ onError?: (error: Error, info: ErrorInfo) => void;
8
+ }
9
+
10
+ interface State {
11
+ hasError: boolean;
12
+ error: Error | null;
13
+ }
14
+
15
+ export class ErrorBoundary extends Component<Props, State> {
16
+ constructor(props: Props) {
17
+ super(props);
18
+ this.state = { hasError: false, error: null };
19
+ }
20
+
21
+ static getDerivedStateFromError(error: Error): State {
22
+ return { hasError: true, error };
23
+ }
24
+
25
+ componentDidCatch(error: Error, info: ErrorInfo) {
26
+ console.error('[ChatWidget] Uncaught error:', error, info.componentStack);
27
+ this.props.onError?.(error, info);
28
+ }
29
+
30
+ handleReset = () => {
31
+ this.setState({ hasError: false, error: null });
32
+ };
33
+
34
+ render() {
35
+ if (this.state.hasError) {
36
+ if (this.props.fallback) return this.props.fallback;
37
+ const color = this.props.primaryColor ?? '#2563EB';
38
+ return (
39
+ <div style={{
40
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
41
+ justifyContent: 'center', height: '100%', padding: 32, textAlign: 'center', gap: 12,
42
+ }}>
43
+ <div style={{ fontSize: 40 }}>⚠️</div>
44
+ <p style={{ fontWeight: 700, color: '#1a2332', margin: 0 }}>Something went wrong</p>
45
+ <p style={{ fontSize: 13, color: '#7b8fa1', lineHeight: 1.6, margin: 0 }}>
46
+ {this.state.error?.message ?? 'An unexpected error occurred'}
47
+ </p>
48
+ <button
49
+ onClick={this.handleReset}
50
+ style={{
51
+ padding: '9px 20px', borderRadius: 10, border: 'none',
52
+ background: color, color: '#fff', cursor: 'pointer', fontWeight: 700, fontSize: 14,
53
+ }}
54
+ >
55
+ Try Again
56
+ </button>
57
+ </div>
58
+ );
59
+ }
60
+ return this.props.children;
61
+ }
62
+ }
@@ -140,9 +140,6 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, apiKey, onNaviga
140
140
  justifyContent: 'flex-end',
141
141
  }}
142
142
  >
143
- <span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
144
- Status
145
- </span>
146
143
  <div
147
144
  role="group"
148
145
  aria-label="Your status"
@@ -0,0 +1,150 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { CallSession, ChatUser } from '../../types';
5
+ import { avatarColor, initials } from '../../utils/chat';
6
+
7
+ export interface MiniCallBarProps {
8
+ session: CallSession;
9
+ primaryColor: string;
10
+ buttonPosition: 'bottom-left' | 'bottom-right';
11
+ onExpand: () => void;
12
+ onEnd: () => void;
13
+ }
14
+
15
+ /**
16
+ * Shown when the user minimizes the widget during an active call (ringing or connected).
17
+ * Sits above the main launcher button so the user can work on the page and return to the call.
18
+ */
19
+ export const MiniCallBar: React.FC<MiniCallBarProps> = ({
20
+ session,
21
+ primaryColor,
22
+ buttonPosition,
23
+ onExpand,
24
+ onEnd,
25
+ }) => {
26
+ const peer = session.peer as ChatUser | null;
27
+ const [duration, setDuration] = useState(0);
28
+
29
+ useEffect(() => {
30
+ if (session.state !== 'connected' || !session.startedAt) return;
31
+ const t = setInterval(() => {
32
+ setDuration(Math.floor((Date.now() - session.startedAt!.getTime()) / 1000));
33
+ }, 1000);
34
+ return () => clearInterval(t);
35
+ }, [session.state, session.startedAt]);
36
+
37
+ const mins = String(Math.floor(duration / 60)).padStart(2, '0');
38
+ const secs = String(duration % 60).padStart(2, '0');
39
+
40
+ const pos: React.CSSProperties =
41
+ buttonPosition === 'bottom-left'
42
+ ? { left: 24, right: 'auto' }
43
+ : { right: 24, left: 'auto' };
44
+
45
+ return (
46
+ <div
47
+ role="toolbar"
48
+ aria-label="Call in progress"
49
+ style={{
50
+ position: 'fixed',
51
+ bottom: 88,
52
+ zIndex: 10000,
53
+ ...pos,
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ gap: 10,
57
+ padding: '10px 14px',
58
+ maxWidth: 'min(360px, calc(100vw - 48px))',
59
+ borderRadius: 14,
60
+ background: `linear-gradient(135deg, ${primaryColor}ee, #0f172a)`,
61
+ color: '#fff',
62
+ boxShadow: '0 10px 32px rgba(0,0,0,0.28)',
63
+ animation: 'cw-miniBarIn 0.28s cubic-bezier(0.22,1,0.36,1)',
64
+ cursor: 'default',
65
+ }}
66
+ >
67
+ <style>{`
68
+ @keyframes cw-miniBarIn {
69
+ from { opacity: 0; transform: translateY(12px); }
70
+ to { opacity: 1; transform: translateY(0); }
71
+ }
72
+ `}</style>
73
+
74
+ <button
75
+ type="button"
76
+ onClick={onExpand}
77
+ title="Open call"
78
+ style={{
79
+ display: 'flex',
80
+ alignItems: 'center',
81
+ gap: 10,
82
+ flex: 1,
83
+ minWidth: 0,
84
+ padding: 0,
85
+ border: 'none',
86
+ background: 'transparent',
87
+ color: 'inherit',
88
+ cursor: 'pointer',
89
+ textAlign: 'left',
90
+ }}
91
+ >
92
+ {peer && (
93
+ <div
94
+ style={{
95
+ width: 40,
96
+ height: 40,
97
+ borderRadius: '50%',
98
+ backgroundColor: avatarColor(peer.name),
99
+ display: 'flex',
100
+ alignItems: 'center',
101
+ justifyContent: 'center',
102
+ fontSize: 14,
103
+ fontWeight: 700,
104
+ flexShrink: 0,
105
+ animation: session.state === 'calling' ? 'cw-pulse 1.5s ease infinite' : 'none',
106
+ }}
107
+ >
108
+ {initials(peer.name)}
109
+ </div>
110
+ )}
111
+ <div style={{ minWidth: 0, flex: 1 }}>
112
+ <div style={{ fontWeight: 700, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
113
+ {peer?.name ?? 'Call'}
114
+ </div>
115
+ <div style={{ fontSize: 12, opacity: 0.9, marginTop: 2 }}>
116
+ {session.state === 'calling' && 'Calling…'}
117
+ {session.state === 'connected' && `${mins}:${secs}`}
118
+ </div>
119
+ </div>
120
+ </button>
121
+
122
+ <button
123
+ type="button"
124
+ onClick={onEnd}
125
+ title="End call"
126
+ style={{
127
+ width: 40,
128
+ height: 40,
129
+ borderRadius: '50%',
130
+ border: 'none',
131
+ background: '#ef4444',
132
+ cursor: 'pointer',
133
+ display: 'flex',
134
+ alignItems: 'center',
135
+ justifyContent: 'center',
136
+ flexShrink: 0,
137
+ boxShadow: '0 2px 10px rgba(239,68,68,0.45)',
138
+ }}
139
+ >
140
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
141
+ <path
142
+ d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 10.8a19.79 19.79 0 01-3.07-8.68A2 2 0 012 0h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.09 7.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 14.92v2z"
143
+ fill="#fff"
144
+ transform="rotate(135 12 12)"
145
+ />
146
+ </svg>
147
+ </button>
148
+ </div>
149
+ );
150
+ };
@@ -1,19 +1,29 @@
1
- import { useState, useCallback } from 'react';
1
+ import { useState, useCallback, useRef } from 'react';
2
2
  import { ChatMessage, ChatUser } from '../types';
3
3
 
4
- export function useChat(initialMessages: ChatMessage[] = []) {
4
+ export interface UseChatOptions {
5
+ onEmitMessage?: (msg: ChatMessage) => void;
6
+ onEmitPause?: (roomId: string, targetUid: string, paused: boolean) => void;
7
+ onEmitReport?: (roomId: string) => void;
8
+ }
9
+
10
+ export function useChat(initialMessages: ChatMessage[] = [], opts: UseChatOptions = {}) {
5
11
  const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
6
12
  const [activeUser, setActiveUser] = useState<ChatUser | null>(null);
7
13
  const [isPaused, setIsPaused] = useState(false);
8
14
  const [isReported, setIsReported] = useState(false);
15
+ const activeUserRef = useRef<ChatUser | null>(null);
16
+ const isPausedRef = useRef(false);
17
+
18
+ const { onEmitMessage, onEmitPause, onEmitReport } = opts;
9
19
 
10
20
  const selectUser = useCallback((user: ChatUser, history: ChatMessage[] = []) => {
11
21
  setActiveUser(user);
22
+ activeUserRef.current = user;
12
23
  setMessages(history);
13
24
  setIsPaused(false);
25
+ isPausedRef.current = false;
14
26
  setIsReported(false);
15
- // TODO: socket.emit('join', { roomId: user.uid });
16
- // TODO: socket.on('message', msg => setMessages(prev => [...prev, msg]));
17
27
  }, []);
18
28
 
19
29
  const sendMessage = useCallback((
@@ -21,11 +31,12 @@ export function useChat(initialMessages: ChatMessage[] = []) {
21
31
  type: ChatMessage['type'] = 'text',
22
32
  extra: Partial<ChatMessage> = {}
23
33
  ) => {
24
- if (!activeUser || isPaused) return;
34
+ const user = activeUserRef.current;
35
+ if (!user || isPausedRef.current) return;
25
36
  const msg: ChatMessage = {
26
37
  id: `msg_${Date.now()}_${Math.random().toString(36).slice(2)}`,
27
38
  senderId: 'me',
28
- receiverId: activeUser.uid,
39
+ receiverId: user.uid,
29
40
  text,
30
41
  timestamp: new Date().toISOString(),
31
42
  type,
@@ -33,16 +44,52 @@ export function useChat(initialMessages: ChatMessage[] = []) {
33
44
  ...extra,
34
45
  };
35
46
  setMessages(prev => [...prev, msg]);
36
- // TODO: socket.emit('message', msg);
37
- }, [activeUser, isPaused]);
47
+ onEmitMessage?.(msg);
48
+ }, [onEmitMessage]);
49
+
50
+ const receiveMessage = useCallback((msg: ChatMessage) => {
51
+ setMessages(prev => {
52
+ if (prev.some(m => m.id === msg.id)) return prev;
53
+ return [...prev, msg];
54
+ });
55
+ }, []);
56
+
57
+ const updateMessageStatus = useCallback((messageId: string, status: ChatMessage['status']) => {
58
+ setMessages(prev => prev.map(m => m.id === messageId ? { ...m, status } : m));
59
+ }, []);
60
+
61
+ const togglePause = useCallback(() => {
62
+ const user = activeUserRef.current;
63
+ if (!user) return;
64
+ const next = !isPausedRef.current;
65
+ setIsPaused(next);
66
+ isPausedRef.current = next;
67
+ const roomId = [user.uid, 'me'].sort().join('_');
68
+ onEmitPause?.(roomId, user.uid, next);
69
+ }, [onEmitPause]);
70
+
71
+ const reportChat = useCallback(() => {
72
+ const user = activeUserRef.current;
73
+ setIsReported(true);
74
+ if (user) {
75
+ const roomId = [user.uid, 'me'].sort().join('_');
76
+ onEmitReport?.(roomId);
77
+ }
78
+ }, [onEmitReport]);
38
79
 
39
- const togglePause = useCallback(() => setIsPaused(p => !p), []);
40
- const reportChat = useCallback(() => { setIsReported(true); /* TODO: API call */ }, []);
41
- const clearChat = useCallback(() => { setMessages([]); setActiveUser(null); }, []);
80
+ const clearChat = useCallback(() => {
81
+ setMessages([]);
82
+ setActiveUser(null);
83
+ activeUserRef.current = null;
84
+ setIsPaused(false);
85
+ isPausedRef.current = false;
86
+ setIsReported(false);
87
+ }, []);
42
88
 
43
89
  return {
44
90
  messages, activeUser, isPaused, isReported,
45
- selectUser, sendMessage, togglePause, reportChat, clearChat,
91
+ selectUser, sendMessage, receiveMessage, updateMessageStatus,
92
+ togglePause, reportChat, clearChat,
46
93
  setMessages,
47
94
  };
48
95
  }
@@ -0,0 +1,228 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useCallback, useState } from 'react';
4
+ import { ChatMessage } from '../types';
5
+
6
+ export type SocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
7
+
8
+ export interface SocketMessageAck {
9
+ messageId: string;
10
+ status: 'delivered' | 'read';
11
+ }
12
+
13
+ export interface UseSocketOptions {
14
+ widgetId: string;
15
+ viewerUid: string;
16
+ /** WebSocket server URL — defaults to http://localhost:3005 */
17
+ serverUrl?: string;
18
+ onMessage?: (msg: ChatMessage) => void;
19
+ onMessageAck?: (ack: SocketMessageAck) => void;
20
+ onChatPaused?: (roomId: string, paused: boolean) => void;
21
+ onCallOffer?: (offer: RTCSessionDescriptionInit, fromUid: string, callId: string) => void;
22
+ onCallAnswer?: (answer: RTCSessionDescriptionInit, fromUid: string, callId: string) => void;
23
+ onIceCandidate?: (candidate: RTCIceCandidateInit, fromUid: string) => void;
24
+ onCallEnd?: (callId: string) => void;
25
+ onError?: (code: string, message: string) => void;
26
+ onUserStatus?: (uid: string, status: 'online' | 'away' | 'offline') => void;
27
+ }
28
+
29
+ const SOCKET_URL = 'http://localhost:3005';
30
+ const RECONNECT_DELAY = 3000;
31
+ const MAX_RECONNECT = 5;
32
+
33
+ export function useSocket(opts: UseSocketOptions) {
34
+ const {
35
+ widgetId, viewerUid, serverUrl = SOCKET_URL,
36
+ onMessage, onMessageAck, onChatPaused,
37
+ onCallOffer, onCallAnswer, onIceCandidate, onCallEnd,
38
+ onError, onUserStatus,
39
+ } = opts;
40
+
41
+ const [status, setStatus] = useState<SocketStatus>('disconnected');
42
+ const wsRef = useRef<WebSocket | null>(null);
43
+ const reconnectCount = useRef(0);
44
+ const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
45
+ const currentRoomRef = useRef<string | null>(null);
46
+ const mountedRef = useRef(true);
47
+
48
+ const send = useCallback((event: string, payload: unknown) => {
49
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
50
+ wsRef.current.send(JSON.stringify({ event, payload }));
51
+ }
52
+ }, []);
53
+
54
+ const connect = useCallback(() => {
55
+ if (!mountedRef.current) return;
56
+ if (wsRef.current) {
57
+ wsRef.current.onclose = null;
58
+ wsRef.current.close();
59
+ }
60
+ setStatus('connecting');
61
+
62
+ const url = `${serverUrl}?widgetId=${encodeURIComponent(widgetId)}&uid=${encodeURIComponent(viewerUid)}`;
63
+ let ws: WebSocket;
64
+ try {
65
+ ws = new WebSocket(url);
66
+ } catch (e) {
67
+ setStatus('error');
68
+ return;
69
+ }
70
+ wsRef.current = ws;
71
+
72
+ ws.onopen = () => {
73
+ if (!mountedRef.current) { ws.close(); return; }
74
+ reconnectCount.current = 0;
75
+ setStatus('connected');
76
+ // Re-join current room if we were in one
77
+ if (currentRoomRef.current) {
78
+ send('join', { roomId: currentRoomRef.current, widgetId });
79
+ }
80
+ };
81
+
82
+ ws.onmessage = (event) => {
83
+ let parsed: { event: string; payload: unknown };
84
+ try { parsed = JSON.parse(event.data as string); }
85
+ catch { return; }
86
+ const { event: ev, payload } = parsed;
87
+ switch (ev) {
88
+ case 'message':
89
+ onMessage?.(payload as ChatMessage);
90
+ break;
91
+ case 'message:ack':
92
+ onMessageAck?.(payload as SocketMessageAck);
93
+ break;
94
+ case 'chat:paused': {
95
+ const p = payload as { roomId: string; paused: boolean };
96
+ onChatPaused?.(p.roomId, p.paused);
97
+ break;
98
+ }
99
+ case 'call-offer': {
100
+ const p = payload as { offer: RTCSessionDescriptionInit; from: string; callId: string };
101
+ onCallOffer?.(p.offer, p.from, p.callId);
102
+ break;
103
+ }
104
+ case 'call-answer': {
105
+ const p = payload as { answer: RTCSessionDescriptionInit; from: string; callId: string };
106
+ onCallAnswer?.(p.answer, p.from, p.callId);
107
+ break;
108
+ }
109
+ case 'ice-candidate': {
110
+ const p = payload as { candidate: RTCIceCandidateInit; from: string };
111
+ onIceCandidate?.(p.candidate, p.from);
112
+ break;
113
+ }
114
+ case 'call-end': {
115
+ const p = payload as { callId: string };
116
+ onCallEnd?.(p.callId);
117
+ break;
118
+ }
119
+ case 'user:status': {
120
+ const p = payload as { uid: string; status: 'online' | 'away' | 'offline' };
121
+ onUserStatus?.(p.uid, p.status);
122
+ break;
123
+ }
124
+ case 'error': {
125
+ const p = payload as { code: string; message: string };
126
+ onError?.(p.code, p.message);
127
+ break;
128
+ }
129
+ }
130
+ };
131
+
132
+ ws.onerror = () => {
133
+ if (!mountedRef.current) return;
134
+ setStatus('error');
135
+ };
136
+
137
+ ws.onclose = () => {
138
+ if (!mountedRef.current) return;
139
+ setStatus('disconnected');
140
+ if (reconnectCount.current < MAX_RECONNECT) {
141
+ reconnectCount.current++;
142
+ reconnectTimer.current = setTimeout(connect, RECONNECT_DELAY * reconnectCount.current);
143
+ }
144
+ };
145
+ }, [widgetId, viewerUid, serverUrl, send, onMessage, onMessageAck, onChatPaused, onCallOffer, onCallAnswer, onIceCandidate, onCallEnd, onError, onUserStatus]);
146
+
147
+ useEffect(() => {
148
+ mountedRef.current = true;
149
+ if (widgetId && viewerUid) connect();
150
+ return () => {
151
+ mountedRef.current = false;
152
+ if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
153
+ wsRef.current?.close();
154
+ };
155
+ // eslint-disable-next-line react-hooks/exhaustive-deps
156
+ }, [widgetId, viewerUid, serverUrl]);
157
+
158
+ const joinRoom = useCallback((roomId: string) => {
159
+ currentRoomRef.current = roomId;
160
+ send('join', { roomId, widgetId });
161
+ }, [send, widgetId]);
162
+
163
+ const leaveRoom = useCallback((roomId: string) => {
164
+ currentRoomRef.current = null;
165
+ send('leave', { roomId });
166
+ }, [send]);
167
+
168
+ const emitMessage = useCallback((msg: ChatMessage) => {
169
+ send('message', msg);
170
+ }, [send]);
171
+
172
+ const emitTypingStart = useCallback((roomId: string) => {
173
+ send('typing_start', { roomId, userId: viewerUid });
174
+ }, [send, viewerUid]);
175
+
176
+ const emitTypingStop = useCallback((roomId: string) => {
177
+ send('typing_stop', { roomId, userId: viewerUid });
178
+ }, [send, viewerUid]);
179
+
180
+ const emitPauseToggle = useCallback((roomId: string, targetUserId: string, paused: boolean) => {
181
+ send('chat:pause', { roomId, targetUserId, paused });
182
+ }, [send]);
183
+
184
+ const emitReport = useCallback((roomId: string, reason?: string) => {
185
+ send('chat:report', { roomId, reporterId: viewerUid, reason });
186
+ }, [send, viewerUid]);
187
+
188
+ const emitBlock = useCallback((blockedUid: string) => {
189
+ send('user:block', { blockedUid });
190
+ }, [send]);
191
+
192
+ const emitUnblock = useCallback((blockedUid: string) => {
193
+ send('user:unblock', { blockedUid });
194
+ }, [send]);
195
+
196
+ const emitTransfer = useCallback((fromRoomId: string, toUserId: string, note?: string) => {
197
+ send('transfer', { fromRoomId, toUserId, note });
198
+ }, [send]);
199
+
200
+ const emitCallOffer = useCallback((offer: RTCSessionDescriptionInit, toUid: string, callId: string) => {
201
+ send('call-offer', { offer, to: toUid, from: viewerUid, callId });
202
+ }, [send, viewerUid]);
203
+
204
+ const emitCallAnswer = useCallback((answer: RTCSessionDescriptionInit, toUid: string, callId: string) => {
205
+ send('call-answer', { answer, to: toUid, callId });
206
+ }, [send]);
207
+
208
+ const emitIceCandidate = useCallback((candidate: RTCIceCandidateInit, toUid: string) => {
209
+ send('ice-candidate', { candidate, to: toUid });
210
+ }, [send]);
211
+
212
+ const emitCallEnd = useCallback((callId: string, toUid?: string) => {
213
+ send('call-end', { callId, to: toUid });
214
+ }, [send]);
215
+
216
+ const emitAddParticipant = useCallback((roomId: string, uid: string) => {
217
+ send('room:add-participant', { roomId, uid });
218
+ }, [send]);
219
+
220
+ return {
221
+ status,
222
+ joinRoom, leaveRoom,
223
+ emitMessage, emitTypingStart, emitTypingStop,
224
+ emitPauseToggle, emitReport, emitBlock, emitUnblock, emitTransfer,
225
+ emitCallOffer, emitCallAnswer, emitIceCandidate, emitCallEnd,
226
+ emitAddParticipant,
227
+ };
228
+ }