polymorph-sdk 0.2.2 → 0.2.4
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.
- package/dist/index.css +1 -1
- package/dist/index.d.ts +56 -19
- package/dist/index.js +6338 -5937
- package/package.json +1 -1
- package/src/ChatThread.tsx +42 -9
- package/src/IdentityForm.tsx +135 -0
- package/src/PolymorphWidget.tsx +68 -32
- package/src/RoomHandler.tsx +22 -2
- package/src/VoiceOverlay.tsx +60 -11
- package/src/WidgetPanel.tsx +103 -74
- package/src/__tests__/IdentityForm.test.tsx +146 -0
- package/src/__tests__/PolymorphWidget.test.tsx +173 -0
- package/src/__tests__/integration.test.ts +58 -0
- package/src/__tests__/usePolymorphSession.test.ts +422 -0
- package/src/index.ts +4 -1
- package/src/styles.module.css +203 -67
- package/src/types.ts +39 -16
- package/src/usePolymorphSession.ts +360 -61
package/package.json
CHANGED
package/src/ChatThread.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
2
|
import styles from "./styles.module.css";
|
|
3
3
|
import type { ChatMessage, SessionStatus } from "./types";
|
|
4
4
|
|
|
@@ -12,8 +12,19 @@ export function ChatThread({
|
|
|
12
12
|
status: SessionStatus;
|
|
13
13
|
}) {
|
|
14
14
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
const isNearBottom = useRef(true);
|
|
16
|
+
const prevItemCount = useRef(0);
|
|
15
17
|
const agentCountAtConnect = useRef<number | null>(null);
|
|
16
18
|
const agentMessageCount = messages.filter((m) => m.role === "agent").length;
|
|
19
|
+
const lastMessageIsUser =
|
|
20
|
+
messages.length > 0 && messages[messages.length - 1].role === "user";
|
|
21
|
+
|
|
22
|
+
const handleScroll = useCallback(() => {
|
|
23
|
+
const el = scrollRef.current;
|
|
24
|
+
if (!el) return;
|
|
25
|
+
isNearBottom.current =
|
|
26
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < 100;
|
|
27
|
+
}, []);
|
|
17
28
|
|
|
18
29
|
// Snapshot agent message count when connecting starts
|
|
19
30
|
if (status === "connecting" && agentCountAtConnect.current === null) {
|
|
@@ -33,23 +44,45 @@ export function ChatThread({
|
|
|
33
44
|
messages[messages.length - 1].role === "user";
|
|
34
45
|
const showThinking = initialConnectThinking || waitingForAgentReply;
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
// Auto-scroll when new messages arrive or thinking indicator appears.
|
|
48
|
+
// Uses render-time comparison instead of useEffect to avoid lint issues
|
|
49
|
+
// with intentional "trigger" dependencies.
|
|
50
|
+
const itemCount = messages.length + (showThinking ? 1 : 0);
|
|
51
|
+
if (itemCount !== prevItemCount.current) {
|
|
52
|
+
prevItemCount.current = itemCount;
|
|
53
|
+
if (isNearBottom.current || lastMessageIsUser) {
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
scrollRef.current?.scrollTo({
|
|
56
|
+
top: scrollRef.current.scrollHeight,
|
|
57
|
+
behavior: "smooth",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
42
62
|
|
|
43
63
|
return (
|
|
44
|
-
<div
|
|
64
|
+
<div
|
|
65
|
+
ref={scrollRef}
|
|
66
|
+
className={styles.chatThread}
|
|
67
|
+
onScroll={handleScroll}
|
|
68
|
+
aria-live="polite"
|
|
69
|
+
aria-relevant="additions"
|
|
70
|
+
>
|
|
45
71
|
{messages.map((msg) => (
|
|
46
72
|
<div
|
|
47
73
|
key={msg.id}
|
|
48
74
|
className={`${styles.messageBubble} ${msg.role === "user" ? styles.userMessage : styles.agentMessage}`}
|
|
49
75
|
style={
|
|
50
|
-
msg.role === "user"
|
|
76
|
+
msg.role === "user"
|
|
77
|
+
? { backgroundColor: primaryColor }
|
|
78
|
+
: msg.senderType === "human"
|
|
79
|
+
? { backgroundColor: `${primaryColor}20` }
|
|
80
|
+
: undefined
|
|
51
81
|
}
|
|
52
82
|
>
|
|
83
|
+
{msg.senderName && (
|
|
84
|
+
<div className={styles.senderLabel}>{msg.senderName}</div>
|
|
85
|
+
)}
|
|
53
86
|
{msg.text}
|
|
54
87
|
{msg.source === "voice" && (
|
|
55
88
|
<div className={styles.voiceLabel}>voice</div>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import styles from "./styles.module.css";
|
|
3
|
+
import type { FieldRequirement, WidgetUser } from "./types";
|
|
4
|
+
|
|
5
|
+
interface IdentityFormProps {
|
|
6
|
+
collectEmail: FieldRequirement;
|
|
7
|
+
collectPhone: FieldRequirement;
|
|
8
|
+
primaryColor: string;
|
|
9
|
+
onSubmit: (user: WidgetUser) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/;
|
|
13
|
+
const PHONE_DIGITS_RE = /\d/g;
|
|
14
|
+
|
|
15
|
+
export function IdentityForm({
|
|
16
|
+
collectEmail,
|
|
17
|
+
collectPhone,
|
|
18
|
+
primaryColor,
|
|
19
|
+
onSubmit,
|
|
20
|
+
}: IdentityFormProps) {
|
|
21
|
+
const [name, setName] = useState("");
|
|
22
|
+
const [email, setEmail] = useState("");
|
|
23
|
+
const [phone, setPhone] = useState("");
|
|
24
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
25
|
+
|
|
26
|
+
const validate = useCallback(() => {
|
|
27
|
+
const e: Record<string, string> = {};
|
|
28
|
+
if (!name.trim()) e.name = "Name is required";
|
|
29
|
+
if (collectEmail === "required" && !email.trim())
|
|
30
|
+
e.email = "Email is required";
|
|
31
|
+
if (email.trim() && !EMAIL_RE.test(email.trim()))
|
|
32
|
+
e.email = "Invalid email format";
|
|
33
|
+
if (collectPhone === "required" && !phone.trim())
|
|
34
|
+
e.phone = "Phone is required";
|
|
35
|
+
if (phone.trim()) {
|
|
36
|
+
const digitCount = (phone.trim().match(PHONE_DIGITS_RE) || []).length;
|
|
37
|
+
if (digitCount < 7 || digitCount > 15)
|
|
38
|
+
e.phone = "Enter a valid phone number (7–15 digits)";
|
|
39
|
+
}
|
|
40
|
+
// "Either" mode: both optional, at least one required
|
|
41
|
+
if (
|
|
42
|
+
collectEmail === "optional" &&
|
|
43
|
+
collectPhone === "optional" &&
|
|
44
|
+
!email.trim() &&
|
|
45
|
+
!phone.trim()
|
|
46
|
+
)
|
|
47
|
+
e.contact = "Please provide either an email or phone number";
|
|
48
|
+
return e;
|
|
49
|
+
}, [name, email, phone, collectEmail, collectPhone]);
|
|
50
|
+
|
|
51
|
+
const handleSubmit = useCallback(
|
|
52
|
+
(ev: React.FormEvent) => {
|
|
53
|
+
ev.preventDefault();
|
|
54
|
+
const e = validate();
|
|
55
|
+
if (Object.keys(e).length > 0) {
|
|
56
|
+
setErrors(e);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
onSubmit({
|
|
60
|
+
name: name.trim(),
|
|
61
|
+
...(email.trim() && { email: email.trim() }),
|
|
62
|
+
...(phone.trim() && { phone: phone.trim() }),
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
[validate, onSubmit, name, email, phone],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<form className={styles.identityForm} onSubmit={handleSubmit}>
|
|
70
|
+
<label className={styles.formField}>
|
|
71
|
+
<span className={styles.formLabel}>
|
|
72
|
+
Name <span className={styles.formError}>*</span>
|
|
73
|
+
</span>
|
|
74
|
+
<input
|
|
75
|
+
className={styles.formInput}
|
|
76
|
+
type="text"
|
|
77
|
+
value={name}
|
|
78
|
+
onChange={(ev) => setName(ev.target.value)}
|
|
79
|
+
placeholder="Your name"
|
|
80
|
+
/>
|
|
81
|
+
{errors.name && <div className={styles.formError}>{errors.name}</div>}
|
|
82
|
+
</label>
|
|
83
|
+
{collectEmail !== "hidden" && (
|
|
84
|
+
<label className={styles.formField}>
|
|
85
|
+
<span className={styles.formLabel}>
|
|
86
|
+
Email{" "}
|
|
87
|
+
{collectEmail === "required" && (
|
|
88
|
+
<span className={styles.formError}>*</span>
|
|
89
|
+
)}
|
|
90
|
+
</span>
|
|
91
|
+
<input
|
|
92
|
+
className={styles.formInput}
|
|
93
|
+
type="email"
|
|
94
|
+
value={email}
|
|
95
|
+
onChange={(ev) => setEmail(ev.target.value)}
|
|
96
|
+
placeholder="you@example.com"
|
|
97
|
+
/>
|
|
98
|
+
{errors.email && (
|
|
99
|
+
<div className={styles.formError}>{errors.email}</div>
|
|
100
|
+
)}
|
|
101
|
+
</label>
|
|
102
|
+
)}
|
|
103
|
+
{collectPhone !== "hidden" && (
|
|
104
|
+
<label className={styles.formField}>
|
|
105
|
+
<span className={styles.formLabel}>
|
|
106
|
+
Phone{" "}
|
|
107
|
+
{collectPhone === "required" && (
|
|
108
|
+
<span className={styles.formError}>*</span>
|
|
109
|
+
)}
|
|
110
|
+
</span>
|
|
111
|
+
<input
|
|
112
|
+
className={styles.formInput}
|
|
113
|
+
type="tel"
|
|
114
|
+
value={phone}
|
|
115
|
+
onChange={(ev) => setPhone(ev.target.value)}
|
|
116
|
+
placeholder="+1 (555) 123-4567"
|
|
117
|
+
/>
|
|
118
|
+
{errors.phone && (
|
|
119
|
+
<div className={styles.formError}>{errors.phone}</div>
|
|
120
|
+
)}
|
|
121
|
+
</label>
|
|
122
|
+
)}
|
|
123
|
+
{errors.contact && (
|
|
124
|
+
<div className={styles.formError}>{errors.contact}</div>
|
|
125
|
+
)}
|
|
126
|
+
<button
|
|
127
|
+
type="submit"
|
|
128
|
+
className={styles.formSubmitButton}
|
|
129
|
+
style={{ background: primaryColor }}
|
|
130
|
+
>
|
|
131
|
+
Start Chat
|
|
132
|
+
</button>
|
|
133
|
+
</form>
|
|
134
|
+
);
|
|
135
|
+
}
|
package/src/PolymorphWidget.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LiveKitRoom } from "@livekit/components-react";
|
|
2
2
|
import { MantineProvider } from "@mantine/core";
|
|
3
3
|
import "@mantine/core/styles.css";
|
|
4
|
-
import { useRef, useState } from "react";
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
import { RoomHandler } from "./RoomHandler";
|
|
6
6
|
import styles from "./styles.module.css";
|
|
7
7
|
import { buildWidgetTheme } from "./theme";
|
|
@@ -9,7 +9,6 @@ import type { WidgetConfig } from "./types";
|
|
|
9
9
|
import { usePolymorphSession } from "./usePolymorphSession";
|
|
10
10
|
import { WidgetPanel } from "./WidgetPanel";
|
|
11
11
|
|
|
12
|
-
// Chat bubble icon for FAB
|
|
13
12
|
function ChatIcon() {
|
|
14
13
|
return (
|
|
15
14
|
<svg
|
|
@@ -23,69 +22,106 @@ function ChatIcon() {
|
|
|
23
22
|
strokeLinecap="round"
|
|
24
23
|
strokeLinejoin="round"
|
|
25
24
|
>
|
|
25
|
+
<title>Chat</title>
|
|
26
26
|
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
|
27
27
|
</svg>
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function FabCloseIcon() {
|
|
32
|
+
return (
|
|
33
|
+
<svg
|
|
34
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
35
|
+
width="24"
|
|
36
|
+
height="24"
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
strokeWidth="2"
|
|
41
|
+
strokeLinecap="round"
|
|
42
|
+
strokeLinejoin="round"
|
|
43
|
+
>
|
|
44
|
+
<title>Close</title>
|
|
45
|
+
<path d="M18 6 6 18" />
|
|
46
|
+
<path d="m6 6 12 12" />
|
|
47
|
+
</svg>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
export function PolymorphWidget(props: WidgetConfig) {
|
|
32
|
-
const { position = "bottom-right", branding } = props;
|
|
33
52
|
const [open, setOpen] = useState(false);
|
|
34
53
|
const session = usePolymorphSession(props);
|
|
35
|
-
const
|
|
54
|
+
const rc = session.resolvedConfig;
|
|
55
|
+
const primaryColor = rc?.primaryColor || "#171717";
|
|
56
|
+
const position = rc?.position || "bottom-right";
|
|
57
|
+
const darkMode = rc?.darkMode ?? false;
|
|
36
58
|
const theme = buildWidgetTheme(primaryColor);
|
|
59
|
+
const { hasUnread, clearUnread, setPanelOpen } = session;
|
|
37
60
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
38
61
|
|
|
62
|
+
// Disconnect only when the user leaves the page (not when closing the panel)
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const handleBeforeUnload = () => session.disconnect();
|
|
65
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
66
|
+
return () => {
|
|
67
|
+
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
68
|
+
session.disconnect();
|
|
69
|
+
};
|
|
70
|
+
}, [session.disconnect]);
|
|
71
|
+
|
|
72
|
+
const audio = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
session.isVoiceEnabled
|
|
75
|
+
? {
|
|
76
|
+
echoCancellation: true,
|
|
77
|
+
noiseSuppression: true,
|
|
78
|
+
autoGainControl: true,
|
|
79
|
+
}
|
|
80
|
+
: false,
|
|
81
|
+
[session.isVoiceEnabled],
|
|
82
|
+
);
|
|
83
|
+
|
|
39
84
|
return (
|
|
40
85
|
<div
|
|
41
86
|
ref={rootRef}
|
|
42
87
|
className={`polymorph-widget ${styles.widgetRoot} ${position === "bottom-left" ? styles.bottomLeft : styles.bottomRight}`}
|
|
43
|
-
style={{ colorScheme:
|
|
88
|
+
style={{ colorScheme: darkMode ? "dark" : "light" }}
|
|
44
89
|
>
|
|
45
90
|
<MantineProvider
|
|
46
91
|
theme={theme}
|
|
47
|
-
forceColorScheme={
|
|
92
|
+
forceColorScheme={darkMode ? "dark" : "light"}
|
|
48
93
|
cssVariablesSelector=".polymorph-widget"
|
|
49
94
|
getRootElement={() => rootRef.current ?? undefined}
|
|
50
95
|
>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
/>
|
|
60
|
-
)}
|
|
96
|
+
<WidgetPanel
|
|
97
|
+
session={session}
|
|
98
|
+
onClose={() => {
|
|
99
|
+
setOpen(false);
|
|
100
|
+
setPanelOpen(false);
|
|
101
|
+
}}
|
|
102
|
+
hidden={!open}
|
|
103
|
+
/>
|
|
61
104
|
<button
|
|
105
|
+
key={session.wiggleKey}
|
|
62
106
|
type="button"
|
|
63
|
-
className={styles.fab}
|
|
107
|
+
className={`${styles.fab} ${open ? styles.fabOpen : ""} ${hasUnread && !open ? styles.fabWiggle : ""}`}
|
|
64
108
|
style={{ backgroundColor: primaryColor }}
|
|
65
109
|
onClick={() => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
110
|
+
const next = !open;
|
|
111
|
+
setOpen(next);
|
|
112
|
+
setPanelOpen(next);
|
|
113
|
+
if (next) clearUnread();
|
|
70
114
|
}}
|
|
71
115
|
>
|
|
72
|
-
<
|
|
116
|
+
{hasUnread && !open && <span className={styles.notificationDot} />}
|
|
117
|
+
{open ? <FabCloseIcon /> : <ChatIcon />}
|
|
73
118
|
</button>
|
|
74
|
-
{/* LiveKit room lives outside WidgetPanel so it mounts before the panel renders */}
|
|
75
119
|
{session.roomConnection && (
|
|
76
120
|
<LiveKitRoom
|
|
77
121
|
token={session.roomConnection.token}
|
|
78
122
|
serverUrl={session.roomConnection.livekitUrl}
|
|
79
123
|
connect={true}
|
|
80
|
-
audio={
|
|
81
|
-
session.isVoiceEnabled
|
|
82
|
-
? {
|
|
83
|
-
echoCancellation: true,
|
|
84
|
-
noiseSuppression: true,
|
|
85
|
-
autoGainControl: true,
|
|
86
|
-
}
|
|
87
|
-
: false
|
|
88
|
-
}
|
|
124
|
+
audio={audio}
|
|
89
125
|
video={false}
|
|
90
126
|
style={{ display: "none" }}
|
|
91
127
|
onDisconnected={session.disconnect}
|
package/src/RoomHandler.tsx
CHANGED
|
@@ -19,6 +19,8 @@ export function RoomHandler({
|
|
|
19
19
|
role: "user" | "agent",
|
|
20
20
|
text: string,
|
|
21
21
|
source: "chat" | "voice",
|
|
22
|
+
senderName?: string,
|
|
23
|
+
senderType?: "human",
|
|
22
24
|
) => void;
|
|
23
25
|
isVoiceEnabled: boolean;
|
|
24
26
|
}) {
|
|
@@ -66,6 +68,8 @@ export function RoomHandler({
|
|
|
66
68
|
const data = JSON.parse(new TextDecoder().decode(payload));
|
|
67
69
|
if (data.type === "chat_response") {
|
|
68
70
|
addMessage("agent", data.text, "chat");
|
|
71
|
+
} else if (data.sender_type === "observer" && data.text) {
|
|
72
|
+
addMessage("agent", data.text, "chat", data.sender_name, "human");
|
|
69
73
|
}
|
|
70
74
|
} catch {
|
|
71
75
|
/* ignore malformed */
|
|
@@ -83,7 +87,23 @@ export function RoomHandler({
|
|
|
83
87
|
const isAgent =
|
|
84
88
|
participant?.kind === ParticipantKind.AGENT ||
|
|
85
89
|
participant?.identity?.includes("agent");
|
|
86
|
-
|
|
90
|
+
// Observer transcriptions come from non-agent remote participants
|
|
91
|
+
// whose identity differs from the local (widget) user
|
|
92
|
+
const isObserver =
|
|
93
|
+
!isAgent &&
|
|
94
|
+
participant &&
|
|
95
|
+
participant.identity !== room.localParticipant.identity;
|
|
96
|
+
if (isObserver) {
|
|
97
|
+
addMessage(
|
|
98
|
+
"agent",
|
|
99
|
+
text,
|
|
100
|
+
"voice",
|
|
101
|
+
participant.name || participant.identity,
|
|
102
|
+
"human",
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
addMessage(isAgent ? "agent" : "user", text, "voice");
|
|
106
|
+
}
|
|
87
107
|
}
|
|
88
108
|
};
|
|
89
109
|
|
|
@@ -95,5 +115,5 @@ export function RoomHandler({
|
|
|
95
115
|
};
|
|
96
116
|
}, [room, addMessage]);
|
|
97
117
|
|
|
98
|
-
return <RoomAudioRenderer
|
|
118
|
+
return isVoiceEnabled ? <RoomAudioRenderer /> : null;
|
|
99
119
|
}
|
package/src/VoiceOverlay.tsx
CHANGED
|
@@ -13,6 +13,8 @@ function VolumeIcon() {
|
|
|
13
13
|
strokeWidth="2"
|
|
14
14
|
strokeLinecap="round"
|
|
15
15
|
strokeLinejoin="round"
|
|
16
|
+
role="img"
|
|
17
|
+
aria-label="Volume on"
|
|
16
18
|
>
|
|
17
19
|
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
|
|
18
20
|
<path d="M16 9a5 5 0 0 1 0 6" />
|
|
@@ -21,6 +23,28 @@ function VolumeIcon() {
|
|
|
21
23
|
);
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
function ScreenShareIcon() {
|
|
27
|
+
return (
|
|
28
|
+
<svg
|
|
29
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
30
|
+
width="14"
|
|
31
|
+
height="14"
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
fill="none"
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
strokeWidth="2"
|
|
36
|
+
strokeLinecap="round"
|
|
37
|
+
strokeLinejoin="round"
|
|
38
|
+
role="img"
|
|
39
|
+
aria-label="Screen share"
|
|
40
|
+
>
|
|
41
|
+
<rect width="20" height="14" x="2" y="3" rx="2" />
|
|
42
|
+
<line x1="8" x2="16" y1="21" y2="21" />
|
|
43
|
+
<line x1="12" x2="12" y1="17" y2="21" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
function VolumeOffIcon() {
|
|
25
49
|
return (
|
|
26
50
|
<svg
|
|
@@ -33,6 +57,8 @@ function VolumeOffIcon() {
|
|
|
33
57
|
strokeWidth="2"
|
|
34
58
|
strokeLinecap="round"
|
|
35
59
|
strokeLinejoin="round"
|
|
60
|
+
role="img"
|
|
61
|
+
aria-label="Volume off"
|
|
36
62
|
>
|
|
37
63
|
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
|
|
38
64
|
<line x1="22" x2="16" y1="9" y2="15" />
|
|
@@ -44,21 +70,27 @@ function VolumeOffIcon() {
|
|
|
44
70
|
interface VoiceOverlayProps {
|
|
45
71
|
isVoiceEnabled: boolean;
|
|
46
72
|
isMicActive: boolean;
|
|
73
|
+
isScreenSharing?: boolean;
|
|
47
74
|
status: SessionStatus;
|
|
48
75
|
onToggleVoice: () => void;
|
|
76
|
+
onToggleScreenShare?: () => void;
|
|
77
|
+
showScreenShare?: boolean;
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
export function VoiceOverlay({
|
|
52
81
|
isVoiceEnabled,
|
|
53
82
|
isMicActive,
|
|
83
|
+
isScreenSharing,
|
|
54
84
|
status,
|
|
55
85
|
onToggleVoice,
|
|
86
|
+
onToggleScreenShare,
|
|
87
|
+
showScreenShare,
|
|
56
88
|
}: VoiceOverlayProps) {
|
|
57
|
-
|
|
89
|
+
const isConnecting = status === "connecting";
|
|
58
90
|
|
|
59
91
|
return (
|
|
60
92
|
<div className={styles.voiceOverlay}>
|
|
61
|
-
{isVoiceEnabled ? (
|
|
93
|
+
{isVoiceEnabled && status === "connected" ? (
|
|
62
94
|
<>
|
|
63
95
|
<span className={styles.voiceBars}>
|
|
64
96
|
<span className={styles.voiceBar} />
|
|
@@ -70,16 +102,33 @@ export function VoiceOverlay({
|
|
|
70
102
|
</span>
|
|
71
103
|
</>
|
|
72
104
|
) : (
|
|
73
|
-
<span className={styles.voiceLabel}>
|
|
105
|
+
<span className={styles.voiceLabel}>
|
|
106
|
+
{isConnecting ? "Connecting..." : "Chat only"}
|
|
107
|
+
</span>
|
|
74
108
|
)}
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
<div className={styles.toggleGroup}>
|
|
110
|
+
{showScreenShare && status === "connected" && (
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
className={`${styles.voiceToggle} ${isScreenSharing ? styles.screenShareToggleActive : ""}`}
|
|
114
|
+
onClick={onToggleScreenShare}
|
|
115
|
+
title={isScreenSharing ? "Stop sharing" : "Share screen"}
|
|
116
|
+
aria-pressed={isScreenSharing}
|
|
117
|
+
>
|
|
118
|
+
<ScreenShareIcon />
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
className={`${styles.voiceToggle} ${isVoiceEnabled && status === "connected" ? styles.voiceToggleActive : ""}`}
|
|
124
|
+
onClick={onToggleVoice}
|
|
125
|
+
disabled={isConnecting}
|
|
126
|
+
title={isVoiceEnabled ? "Turn off voice" : "Turn on voice"}
|
|
127
|
+
aria-pressed={isVoiceEnabled}
|
|
128
|
+
>
|
|
129
|
+
{isVoiceEnabled ? <VolumeIcon /> : <VolumeOffIcon />}
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
83
132
|
</div>
|
|
84
133
|
);
|
|
85
134
|
}
|