@vanira/sdk-react-native 0.0.2

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 (148) hide show
  1. package/README.md +239 -0
  2. package/package.json +53 -0
  3. package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
  4. package/src/__tests__/adapters.test.ts +475 -0
  5. package/src/__tests__/httpResponse.test.ts +25 -0
  6. package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
  7. package/src/__tests__/mocks/react-native-permissions.ts +15 -0
  8. package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
  9. package/src/__tests__/mocks/react-native.ts +28 -0
  10. package/src/__tests__/preset.test.ts +239 -0
  11. package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
  12. package/src/__tests__/storage.test.ts +211 -0
  13. package/src/__tests__/webrtcSignaling.test.ts +42 -0
  14. package/src/adapters/PeerConnectionAdapter.ts +101 -0
  15. package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
  16. package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
  17. package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
  18. package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
  19. package/src/adapters/browser/index.ts +4 -0
  20. package/src/adapters/interfaces.ts +84 -0
  21. package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
  22. package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
  23. package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
  24. package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
  25. package/src/adapters/react-native/callAudioRouting.ts +115 -0
  26. package/src/adapters/react-native/decodeUtf8.ts +72 -0
  27. package/src/adapters/react-native/index.ts +4 -0
  28. package/src/adapters/react-native/rnUploadFile.ts +76 -0
  29. package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
  30. package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
  31. package/src/adapters/storage/StorageAdapter.ts +21 -0
  32. package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
  33. package/src/adapters/storage/index.ts +7 -0
  34. package/src/api/services/ChatService.ts +304 -0
  35. package/src/api/services/ConfigService.ts +33 -0
  36. package/src/assets/icons.js +35 -0
  37. package/src/cdn.ts +68 -0
  38. package/src/core/CallSessionStore.ts +137 -0
  39. package/src/core/DraggableController.ts +83 -0
  40. package/src/core/SessionManager.ts +322 -0
  41. package/src/core/VaniraAI.ts +464 -0
  42. package/src/core/WebRTCClient.ts +1012 -0
  43. package/src/core/httpResponse.ts +22 -0
  44. package/src/core/iceServers.ts +18 -0
  45. package/src/core/toolCallNormalize.ts +80 -0
  46. package/src/core/voice-client.js +236 -0
  47. package/src/core/webrtcSignaling.ts +72 -0
  48. package/src/index.js +34 -0
  49. package/src/index.ts +6 -0
  50. package/src/platforms/browser.ts +67 -0
  51. package/src/platforms/react-native.ts +105 -0
  52. package/src/presets/BookingCalendarModal.tsx +457 -0
  53. package/src/presets/CameraModal.tsx +576 -0
  54. package/src/presets/DynamicFormModal.tsx +378 -0
  55. package/src/presets/NativePresetRenderer.tsx +350 -0
  56. package/src/presets/NavigateHandler.tsx +75 -0
  57. package/src/presets/PresetHost.tsx +155 -0
  58. package/src/presets/PresetShellModal.tsx +97 -0
  59. package/src/presets/UploadModal.tsx +321 -0
  60. package/src/presets/calendar/calendarUtils.ts +386 -0
  61. package/src/presets/call/CallSpeakerToggle.tsx +59 -0
  62. package/src/presets/call/callAudioRouting.ts +2 -0
  63. package/src/presets/call/useCallSpeaker.ts +31 -0
  64. package/src/presets/camera/cameraPermissions.ts +18 -0
  65. package/src/presets/camera/cameraStream.ts +19 -0
  66. package/src/presets/camera/cameraUtils.ts +21 -0
  67. package/src/presets/camera/useLivenessFlow.ts +95 -0
  68. package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
  69. package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
  70. package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
  71. package/src/presets/chalkboard/boardAbort.ts +36 -0
  72. package/src/presets/chalkboard/boardQueue.ts +620 -0
  73. package/src/presets/chalkboard/chalkboardSession.ts +75 -0
  74. package/src/presets/chalkboard/drawUtils.ts +123 -0
  75. package/src/presets/chalkboard/textUtils.ts +109 -0
  76. package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
  77. package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
  78. package/src/presets/form/formValidation.ts +104 -0
  79. package/src/presets/form/parseFormFields.ts +171 -0
  80. package/src/presets/host/HostElementPresetHandler.tsx +155 -0
  81. package/src/presets/host/hostPresetBridge.ts +71 -0
  82. package/src/presets/index.ts +63 -0
  83. package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
  84. package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
  85. package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
  86. package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
  87. package/src/presets/liveScreen/liveScreenSession.ts +73 -0
  88. package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
  89. package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
  90. package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
  91. package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
  92. package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
  93. package/src/presets/liveVision/liveVisionSession.ts +75 -0
  94. package/src/presets/liveVision/liveVisionUpload.ts +62 -0
  95. package/src/presets/navigation/internalRouteRegistry.ts +25 -0
  96. package/src/presets/navigation/navigationBridge.ts +76 -0
  97. package/src/presets/navigation/navigationTypes.ts +12 -0
  98. package/src/presets/parseToolCall.ts +60 -0
  99. package/src/presets/presetClientAdapter.ts +29 -0
  100. package/src/presets/presetCompletion.ts +91 -0
  101. package/src/presets/presetEventHelpers.ts +45 -0
  102. package/src/presets/registry.ts +128 -0
  103. package/src/presets/streaming/mediaFrameUpload.ts +93 -0
  104. package/src/presets/types.ts +74 -0
  105. package/src/presets/upload/pickUploadFile.ts +256 -0
  106. package/src/presets/upload/uploadFormats.ts +163 -0
  107. package/src/presets/upload/uploadUtils.ts +68 -0
  108. package/src/react/PresetRenderer.tsx +144 -0
  109. package/src/react/index.ts +1 -0
  110. package/src/runtime/browserRuntime.ts +54 -0
  111. package/src/runtime/platform.ts +17 -0
  112. package/src/runtime/reactNativeRuntime.ts +68 -0
  113. package/src/runtime/resolveRuntimeConfig.ts +75 -0
  114. package/src/runtime/runtimeBundles.ts +74 -0
  115. package/src/runtime/types.ts +135 -0
  116. package/src/types/react-native-incall-manager.d.ts +17 -0
  117. package/src/types/react-native-webrtc.d.ts +47 -0
  118. package/src/types.ts +133 -0
  119. package/src/ui/VaniraWidget.ts +87 -0
  120. package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
  121. package/src/ui/abstraction/interfaces.ts +12 -0
  122. package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
  123. package/src/ui/components/AvatarView.ts +81 -0
  124. package/src/ui/components/ChatWindow.ts +263 -0
  125. package/src/ui/components/FloatingButton.ts +163 -0
  126. package/src/ui/components/FloatingWelcomeChips.ts +137 -0
  127. package/src/ui/components/Panel.ts +120 -0
  128. package/src/ui/components/VoiceOrb.ts +79 -0
  129. package/src/ui/components/VoiceOverlay.ts +497 -0
  130. package/src/ui/components/index.ts +7 -0
  131. package/src/ui/factory/WidgetFactory.ts +16 -0
  132. package/src/ui/icons_data.ts +2 -0
  133. package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
  134. package/src/ui/presets/types.ts +16 -0
  135. package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
  136. package/src/ui/styles/index.ts +323 -0
  137. package/src/ui/styles/keyframes.ts +76 -0
  138. package/src/ui/styles/theme.ts +57 -0
  139. package/src/ui/styles/widget.css.ts +838 -0
  140. package/src/ui/utils.ts +37 -0
  141. package/src/ui/views/AbstractChatView.ts +93 -0
  142. package/src/ui/views/AbstractVoiceView.ts +57 -0
  143. package/src/ui/views/AvatarOnlyView.ts +78 -0
  144. package/src/ui/views/ChatAvatarView.ts +66 -0
  145. package/src/ui/views/ChatOnlyView.ts +28 -0
  146. package/src/ui/views/ChatVoiceView.ts +15 -0
  147. package/src/ui/views/VoiceOnlyView.ts +25 -0
  148. package/src/ui/views/index.ts +5 -0
@@ -0,0 +1,12 @@
1
+ export interface IChatAdapter {
2
+ sendMessage(text: string, agentId: string, prospectId: string, chatId: string | null, onResponse: (response: any) => void, onStream?: (text: string) => void, onChatIdUpdate?: (newId: string) => void, widgetId?: string, pkKey?: string): Promise<void>;
3
+ sendFile?(file: File): Promise<void>;
4
+ likeDislike?(messageId: string, action: 'like' | 'dislike'): Promise<void>;
5
+ }
6
+
7
+ export interface IWidgetProvider {
8
+ create_call(): Promise<void>;
9
+ end_call(): void;
10
+ ui_renderer(root: ShadowRoot): void;
11
+ initialize(config: any): Promise<void>;
12
+ }
@@ -0,0 +1,42 @@
1
+ import { IChatAdapter } from '../abstraction/interfaces';
2
+ import { ChatService } from '../../api/services/ChatService';
3
+
4
+ export class VaniraChatAdapter implements IChatAdapter {
5
+ async sendMessage(
6
+ text: string,
7
+ agentId: string,
8
+ prospectId: string,
9
+ chatId: string | null,
10
+ onResponse: (response: any) => void,
11
+ onStream?: (text: string) => void,
12
+ onChatIdUpdate?: (newId: string, newConversationId?: string) => void,
13
+ widgetId?: string,
14
+ pkKey?: string
15
+ ): Promise<void> {
16
+ return ChatService.sendChatMessage(
17
+ agentId,
18
+ prospectId,
19
+ text,
20
+ chatId,
21
+ (stream) => {
22
+ if (onStream) onStream(stream);
23
+ },
24
+ onResponse,
25
+ (newId, newConversationId) => {
26
+ if (onChatIdUpdate && newId) onChatIdUpdate(newId, newConversationId || undefined);
27
+ },
28
+ widgetId,
29
+ pkKey
30
+ );
31
+ }
32
+
33
+ async sendFile(file: File): Promise<void> {
34
+ console.warn("sendFile not implemented for VaniraChatAdapter yet - Incompatible function adapted", file);
35
+ throw new Error("Method not implemented.");
36
+ }
37
+
38
+ async likeDislike(messageId: string, action: 'like' | 'dislike'): Promise<void> {
39
+ console.warn("likeDislike not implemented for VaniraChatAdapter yet - Incompatible function adapted", messageId, action);
40
+ // Implement API call here when available
41
+ }
42
+ }
@@ -0,0 +1,81 @@
1
+
2
+ export class AvatarView {
3
+ private element: HTMLDivElement;
4
+ private videoElement: HTMLVideoElement;
5
+ private placeholderContainer: HTMLDivElement;
6
+
7
+ constructor(avatarUrl?: string | null) {
8
+ this.element = document.createElement('div');
9
+ this.element.className = 'avatar-container';
10
+ this.element.style.position = 'relative';
11
+ this.element.style.width = '100%';
12
+ this.element.style.height = '100%';
13
+ this.element.style.background = '#ffffff'; // White card background
14
+ this.element.style.borderRadius = '16px';
15
+ this.element.style.overflow = 'hidden';
16
+
17
+ // 1. Placeholder Container (Centered Circle)
18
+ this.placeholderContainer = document.createElement('div');
19
+ this.placeholderContainer.style.width = '100%';
20
+ this.placeholderContainer.style.height = '100%';
21
+ this.placeholderContainer.style.display = 'flex';
22
+ this.placeholderContainer.style.alignItems = 'center';
23
+ this.placeholderContainer.style.justifyContent = 'center';
24
+
25
+ // Determine Avatar Source
26
+ // Use provided URL or fallback to default video
27
+ const sourceUrl = avatarUrl || "https://www.simli.com/jenna.mp4";
28
+ const isVideo = sourceUrl.endsWith('.mp4') || sourceUrl.endsWith('.webm');
29
+
30
+ let mediaElement: HTMLElement;
31
+
32
+ if (isVideo) {
33
+ const vid = document.createElement('video');
34
+ vid.src = sourceUrl;
35
+ vid.muted = true;
36
+ vid.loop = true;
37
+ vid.autoplay = true;
38
+ vid.playsInline = true;
39
+ mediaElement = vid;
40
+ } else {
41
+ const img = document.createElement('img');
42
+ img.src = sourceUrl;
43
+ mediaElement = img;
44
+ }
45
+
46
+ // Apply Circle Styles
47
+ mediaElement.style.width = '140px';
48
+ mediaElement.style.height = '140px';
49
+ mediaElement.style.borderRadius = '50%';
50
+ mediaElement.style.objectFit = 'cover';
51
+ mediaElement.style.backgroundColor = '#f3f4f6';
52
+
53
+ this.placeholderContainer.appendChild(mediaElement);
54
+ this.element.appendChild(this.placeholderContainer);
55
+
56
+ // 2. Video Element
57
+ this.videoElement = document.createElement('video');
58
+ this.videoElement.className = 'avatar-video';
59
+ this.videoElement.autoplay = true;
60
+ this.videoElement.playsInline = true;
61
+ this.videoElement.muted = true; // Often required for autoplay
62
+ this.videoElement.style.display = 'none';
63
+
64
+ this.element.appendChild(this.videoElement);
65
+
66
+ // Initial State - NO DEFAULT ICON per user request
67
+ // this.setPlaceholder(this.currentGender as any);
68
+ }
69
+
70
+ public getElement(): HTMLDivElement {
71
+ return this.element;
72
+ }
73
+
74
+ public setStream(stream: MediaStream) {
75
+ this.videoElement.srcObject = stream;
76
+ this.videoElement.style.display = 'block';
77
+ this.placeholderContainer.style.display = 'none';
78
+ }
79
+
80
+
81
+ }
@@ -0,0 +1,263 @@
1
+
2
+ import { icons } from '../styles';
3
+ import { cleanLatexText } from '../utils';
4
+
5
+ export interface ChatMessage {
6
+ role: 'user' | 'assistant';
7
+ content: string;
8
+ }
9
+
10
+ export class ChatWindow {
11
+ private element: HTMLDivElement;
12
+ private messageContainer: HTMLDivElement;
13
+ private inputArea: HTMLDivElement;
14
+ private input: HTMLInputElement;
15
+ private sendBtn: HTMLButtonElement;
16
+ private typingIndicator: HTMLDivElement | null = null;
17
+ private resolveBar: HTMLDivElement | null = null;
18
+
19
+ constructor(
20
+ private onSend: (message: string) => void
21
+ ) {
22
+ this.element = document.createElement('div');
23
+ this.element.style.display = 'flex';
24
+ this.element.style.flexDirection = 'column';
25
+ this.element.style.flex = '1';
26
+ this.element.style.overflow = 'hidden';
27
+ this.element.style.minHeight = '0'; // Crucial for nested flex scrolling
28
+
29
+ // Messages Area
30
+ this.messageContainer = document.createElement('div');
31
+ this.messageContainer.className = 'chat-messages';
32
+
33
+ // Input Area
34
+ this.inputArea = document.createElement('div');
35
+ this.inputArea.className = 'chat-input-area';
36
+
37
+ this.input = document.createElement('input');
38
+ this.input.className = 'chat-input';
39
+ this.input.placeholder = 'Type a message...';
40
+ this.input.addEventListener('keypress', (e) => {
41
+ if (e.key === 'Enter') this.handleSend();
42
+ });
43
+
44
+ this.sendBtn = document.createElement('button');
45
+ this.sendBtn.className = 'chat-send-btn';
46
+ this.sendBtn.innerHTML = icons.send;
47
+ this.sendBtn.onclick = () => this.handleSend();
48
+
49
+ this.inputArea.appendChild(this.input);
50
+ this.inputArea.appendChild(this.sendBtn);
51
+
52
+ // Branding Area
53
+ const brandingArea = document.createElement('div');
54
+ brandingArea.className = 'branding-footer';
55
+ brandingArea.innerHTML = `Technology Powered by <b>Vanira AI</b>`;
56
+
57
+ this.element.appendChild(this.messageContainer);
58
+ this.element.appendChild(this.inputArea);
59
+ this.element.appendChild(brandingArea);
60
+ }
61
+
62
+ public getElement(): HTMLDivElement {
63
+ return this.element;
64
+ }
65
+
66
+ private formatTime(date: Date = new Date()): string {
67
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
68
+ }
69
+
70
+ public addMessage(role: 'user' | 'assistant', content: string, timestamp?: number) {
71
+ const wrapper = document.createElement('div');
72
+ wrapper.style.cssText = `
73
+ display: flex;
74
+ flex-direction: column;
75
+ align-items: ${role === 'user' ? 'flex-end' : 'flex-start'};
76
+ margin-bottom: 2px;
77
+ `;
78
+
79
+ const msgDiv = document.createElement('div');
80
+ msgDiv.className = `chat-message ${role}`;
81
+ msgDiv.textContent = cleanLatexText(content);
82
+ wrapper.appendChild(msgDiv);
83
+
84
+ const ts = document.createElement('span');
85
+ ts.style.cssText = `
86
+ font-size: 10px;
87
+ color: #9ca3af;
88
+ margin: 2px 4px 6px;
89
+ display: block;
90
+ `;
91
+ ts.textContent = this.formatTime(timestamp ? new Date(timestamp) : undefined);
92
+ wrapper.appendChild(ts);
93
+
94
+ this.messageContainer.appendChild(wrapper);
95
+ this.scrollToBottom();
96
+ }
97
+
98
+ public updateLastAssistantMessage(content: string) {
99
+ // The last child is the wrapper div; the message is its first child
100
+ const lastWrapper = this.messageContainer.lastElementChild as HTMLElement;
101
+ if (lastWrapper) {
102
+ const lastMsg = lastWrapper.querySelector('.chat-message.assistant') as HTMLElement;
103
+ if (lastMsg) {
104
+ lastMsg.textContent = cleanLatexText(content);
105
+ this.scrollToBottom();
106
+ return;
107
+ }
108
+ }
109
+ this.addMessage('assistant', content);
110
+ }
111
+
112
+ public addButtons(buttons: Array<{ title: string; payload: string }>) {
113
+ const buttonsContainer = document.createElement('div');
114
+ buttonsContainer.className = 'chat-buttons-container';
115
+ buttonsContainer.style.display = 'flex';
116
+ buttonsContainer.style.flexWrap = 'wrap';
117
+ buttonsContainer.style.gap = '8px';
118
+ buttonsContainer.style.marginTop = '8px';
119
+ buttonsContainer.style.marginBottom = '12px';
120
+ buttonsContainer.style.justifyContent = 'flex-start';
121
+
122
+ buttons.forEach(btn => {
123
+ const buttonEl = document.createElement('button');
124
+ buttonEl.className = 'chat-suggestion-chip';
125
+ buttonEl.textContent = btn.title;
126
+ // Basic styles if CSS class not defined
127
+ buttonEl.style.padding = '6px 12px';
128
+ buttonEl.style.borderRadius = '16px';
129
+ buttonEl.style.border = '1px solid #e5e7eb';
130
+ buttonEl.style.backgroundColor = 'white';
131
+ buttonEl.style.fontSize = '12px';
132
+ buttonEl.style.color = '#4b5563';
133
+ buttonEl.style.cursor = 'pointer';
134
+ buttonEl.style.transition = 'all 0.2s';
135
+
136
+ buttonEl.onmouseenter = () => {
137
+ buttonEl.style.backgroundColor = '#f3f4f6';
138
+ buttonEl.style.borderColor = '#d1d5db';
139
+ };
140
+ buttonEl.onmouseleave = () => {
141
+ buttonEl.style.backgroundColor = 'white';
142
+ buttonEl.style.borderColor = '#e5e7eb';
143
+ };
144
+
145
+ buttonEl.onclick = () => {
146
+ this.handleSend(btn.payload);
147
+ };
148
+
149
+ buttonsContainer.appendChild(buttonEl);
150
+ });
151
+
152
+ // Append to the last message if probable? Or just append.
153
+ // Usually buttons belong to the last assistant message.
154
+ // For simplicity, append to container.
155
+ this.messageContainer.appendChild(buttonsContainer);
156
+ this.scrollToBottom();
157
+ }
158
+
159
+ private handleSend(overrideText?: string) {
160
+ const text = overrideText || this.input.value.trim();
161
+ if (!text) return;
162
+
163
+ this.onSend(text);
164
+ if (!overrideText) {
165
+ this.input.value = '';
166
+ }
167
+ }
168
+
169
+ public setTyping(isTyping: boolean) {
170
+ if (this.typingIndicator) {
171
+ this.typingIndicator.remove();
172
+ this.typingIndicator = null;
173
+ }
174
+
175
+ if (isTyping) {
176
+ this.typingIndicator = document.createElement('div');
177
+ this.typingIndicator.className = 'typing-indicator';
178
+
179
+ // 3 dots
180
+ for (let i = 0; i < 3; i++) {
181
+ const dot = document.createElement('div');
182
+ dot.className = 'typing-dot';
183
+ this.typingIndicator.appendChild(dot);
184
+ }
185
+
186
+ this.messageContainer.appendChild(this.typingIndicator);
187
+ this.scrollToBottom();
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Inject a "Voice call ended" card into the message list.
193
+ * @param durationMs call duration in milliseconds
194
+ * @param timestamp unix ms when the call started (for ordering across restore)
195
+ */
196
+ public addCallRecord(durationMs: number, timestamp: number = Date.now()) {
197
+ const mins = Math.floor(durationMs / 60000);
198
+ const secs = Math.floor((durationMs % 60000) / 1000);
199
+ const label = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
200
+
201
+ const card = document.createElement('div');
202
+ card.dataset.callTimestamp = String(timestamp);
203
+ card.style.cssText = `
204
+ display: flex; justify-content: center;
205
+ margin: 10px 16px;
206
+ `;
207
+
208
+ card.innerHTML = `
209
+ <div style="
210
+ display: flex; align-items: center; gap: 8px;
211
+ background: #f3f4f6; border: 1px solid #e5e7eb;
212
+ border-radius: 12px; padding: 8px 14px;
213
+ font-size: 12px; color: #6b7280;
214
+ max-width: 210px; width: fit-content;
215
+ ">
216
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
217
+ <rect x="2" y="9" width="4" height="12" rx="2"/>
218
+ <rect x="9" y="5" width="4" height="16" rx="2"/>
219
+ <rect x="16" y="2" width="4" height="20" rx="2"/>
220
+ </svg>
221
+ <span>
222
+ <span style="font-weight:500; color:#374151;">Voice call ended</span>
223
+ <span style="margin-left:6px; color:#9ca3af;">·</span>
224
+ <span style="margin-left:6px;">${label}</span>
225
+ </span>
226
+ </div>
227
+ `;
228
+
229
+ this.messageContainer.appendChild(card);
230
+ this.scrollToBottom();
231
+ }
232
+
233
+ public clearMessages() {
234
+ this.messageContainer.innerHTML = '';
235
+ }
236
+
237
+ public setResolveCallback(onResolve: () => void) {
238
+ if (this.resolveBar) {
239
+ this.resolveBar.remove();
240
+ }
241
+
242
+ this.resolveBar = document.createElement('div');
243
+ this.resolveBar.className = 'chat-resolve-bar';
244
+
245
+ const link = document.createElement('button');
246
+ link.className = 'chat-resolve-link';
247
+ link.textContent = 'Mark as resolved, start new';
248
+ link.onclick = () => {
249
+ if (confirm('Are you sure you want to mark this conversation as resolved and start fresh?')) {
250
+ onResolve();
251
+ }
252
+ };
253
+
254
+ this.resolveBar.appendChild(link);
255
+
256
+ // Insert resolveBar right above inputArea
257
+ this.element.insertBefore(this.resolveBar, this.inputArea);
258
+ }
259
+
260
+ private scrollToBottom() {
261
+ this.messageContainer.scrollTop = this.messageContainer.scrollHeight;
262
+ }
263
+ }
@@ -0,0 +1,163 @@
1
+
2
+ import { icons } from '../styles';
3
+
4
+ export class FloatingButton {
5
+ private element: HTMLButtonElement;
6
+ private iconContainer: HTMLElement;
7
+
8
+ private posX = 0;
9
+ private posY = 0;
10
+ private isDragging = false;
11
+ private startX = 0;
12
+ private startY = 0;
13
+ private currentX = 0;
14
+ private currentY = 0;
15
+ private hasMoved = false;
16
+
17
+ constructor(
18
+ private onClick: () => void,
19
+ private onMove?: (x: number, y: number) => void,
20
+ private onMoveEnd?: (x: number, y: number) => void,
21
+ initialIcon: string = icons.chat
22
+ ) {
23
+ this.element = document.createElement('button');
24
+ this.element.className = 'widget-fab';
25
+
26
+ this.element.onpointerdown = this.dragStart.bind(this);
27
+
28
+ this.iconContainer = document.createElement('div');
29
+ this.iconContainer.style.cssText = 'display:flex; align-items:center; justify-content:center; width:100%; height:100%;';
30
+ this.iconContainer.innerHTML = initialIcon;
31
+
32
+ this.element.appendChild(this.iconContainer);
33
+ }
34
+
35
+ private dragStart(e: PointerEvent) {
36
+ if (e.button !== 0) return; // Only left click
37
+
38
+ const rect = this.element.getBoundingClientRect();
39
+ this.isDragging = true;
40
+ this.hasMoved = false;
41
+ this.startX = e.clientX;
42
+ this.startY = e.clientY;
43
+ this.currentX = rect.left;
44
+ this.currentY = rect.top;
45
+
46
+ this.element.setPointerCapture(e.pointerId);
47
+ this.element.style.transition = 'none';
48
+ this.element.style.cursor = 'grabbing';
49
+
50
+ const onPointerMove = (moveEvent: PointerEvent) => {
51
+ if (!this.isDragging) return;
52
+
53
+ const dx = moveEvent.clientX - this.startX;
54
+ const dy = moveEvent.clientY - this.startY;
55
+
56
+ if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
57
+ this.hasMoved = true;
58
+ }
59
+
60
+ if (this.hasMoved) {
61
+ this.posX = this.currentX + dx;
62
+ this.posY = this.currentY + dy;
63
+
64
+ // Clamp to viewport
65
+ this.posX = Math.max(0, Math.min(this.posX, window.innerWidth - this.element.offsetWidth));
66
+ this.posY = Math.max(0, Math.min(this.posY, window.innerHeight - this.element.offsetHeight));
67
+
68
+ this.element.style.left = `${this.posX}px`;
69
+ this.element.style.top = `${this.posY}px`;
70
+ this.element.style.right = 'auto';
71
+ this.element.style.bottom = 'auto';
72
+
73
+ this.onMove?.(this.posX, this.posY);
74
+ }
75
+ };
76
+
77
+ const onPointerUp = (upEvent: PointerEvent) => {
78
+ this.isDragging = false;
79
+ if (this.element.hasPointerCapture(upEvent.pointerId)) {
80
+ this.element.releasePointerCapture(upEvent.pointerId);
81
+ }
82
+ this.element.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
83
+ this.element.style.cursor = 'pointer';
84
+
85
+ window.removeEventListener('pointermove', onPointerMove);
86
+ window.removeEventListener('pointerup', onPointerUp);
87
+
88
+ if (!this.hasMoved) {
89
+ this.onClick();
90
+ } else {
91
+ this.onMoveEnd?.(this.posX, this.posY);
92
+ }
93
+ };
94
+
95
+ window.addEventListener('pointermove', onPointerMove);
96
+ window.addEventListener('pointerup', onPointerUp);
97
+ }
98
+
99
+ public getElement(): HTMLButtonElement {
100
+ return this.element;
101
+ }
102
+
103
+ public setIcon(iconHtml: string) {
104
+ this.iconContainer.innerHTML = iconHtml;
105
+ }
106
+
107
+ public setPosition(positionStyle: string) {
108
+ this.element.style.cssText = ''; // Clear previous
109
+ const styles = positionStyle.split(';').filter(s => s.trim());
110
+ styles.forEach(s => {
111
+ const [prop, val] = s.split(':').map(str => str.trim());
112
+ if (prop && val) (this.element.style as any)[prop] = val;
113
+ });
114
+ this.element.style.position = 'fixed';
115
+ this.element.style.zIndex = '2147483647';
116
+ }
117
+
118
+ public updateCoordinates(x: number, y: number) {
119
+ this.element.style.left = `${x}px`;
120
+ this.element.style.top = `${y}px`;
121
+ this.element.style.right = 'auto';
122
+ this.element.style.bottom = 'auto';
123
+ }
124
+
125
+ public static get styles() {
126
+ return `
127
+ .widget-fab {
128
+ position: fixed;
129
+ z-index: 9999;
130
+ width: 60px;
131
+ height: 60px;
132
+ border-radius: 50%;
133
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
134
+ border: none;
135
+ cursor: pointer;
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ box-shadow: var(--shadow);
140
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
141
+ animation: fadeInScale 0.4s ease-out;
142
+ touch-action: none;
143
+ padding: 0;
144
+ margin: 0;
145
+ }
146
+
147
+ .widget-fab:hover {
148
+ transform: scale(1.05);
149
+ box-shadow: var(--shadow-lg);
150
+ }
151
+
152
+ .widget-fab:active {
153
+ transform: scale(0.95);
154
+ }
155
+
156
+ .widget-fab svg {
157
+ width: 26px;
158
+ height: 26px;
159
+ color: white;
160
+ }
161
+ `;
162
+ }
163
+ }