Skip to content

Commit dd26e9b

Browse files
committed
feat(embed): add topInset, bottomInset, and safe-area delivery v1.2.0
1 parent fb82031 commit dd26e9b

10 files changed

Lines changed: 105 additions & 7 deletions

‎dist/ChattyEmbedView.d.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export declare const chattyDefaultEmbedBaseUrl = "https://chatty.personaliai.com
33
export declare const BRIDGE_SHIM_JS = "\n(function() {\n if (window.__chattyBridgeInstalled) { return true; }\n window.__chattyBridgeInstalled = true;\n // MessageEvent's constructor only accepts a Window or MessagePort for\n // 'source' (not a generic EventTarget, and definitely not a plain object\n // literal) \u2014 a MessagePort from a throwaway MessageChannel is the only\n // one of those we can actually construct standalone. Its native\n // postMessage is shadowed with our own override (own-property lookup\n // beats the prototype's), so the port itself is never really used to\n // send anything, only to satisfy the type check and as a stable\n // identity for the page's own 'e.source !== window.parent' filter.\n var fakeParent = new MessageChannel().port1;\n fakeParent.postMessage = function(data) {\n try { window.ReactNativeWebView.postMessage(JSON.stringify(data)); } catch (e) {}\n };\n try {\n Object.defineProperty(window, 'parent', { get: function() { return fakeParent; }, configurable: true });\n } catch (e) {}\n window.__chattyDeliver = function(json) {\n try {\n var data = JSON.parse(json);\n window.dispatchEvent(new MessageEvent('message', { data: data, source: fakeParent }));\n } catch (e) {}\n };\n // Suppress raw browser window.alert() calls (e.g. \"Browser push notifications\n // are not supported on this browser\" from EmbedClient.tsx) which would otherwise\n // render as a jarring browser dialog.\n try {\n window.alert = function(msg) {\n try { console.log('[ChattyBridge] alert suppressed: ' + msg); } catch (e) {}\n };\n } catch (e) {}\n // Intercept getUserMedia so native host knows mic access was requested.\n try {\n if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {\n var _origGUM = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);\n navigator.mediaDevices.getUserMedia = function(constraints) {\n try { fakeParent.postMessage({ type: 'chatty:mic-requested' }); } catch (e) {}\n return _origGUM(constraints);\n };\n }\n } catch (e) {}\n // Intercept geolocation so native host knows location access was requested.\n try {\n if (navigator.geolocation && navigator.geolocation.getCurrentPosition) {\n var _origGCP = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation);\n navigator.geolocation.getCurrentPosition = function(success, error, options) {\n try { fakeParent.postMessage({ type: 'chatty:location-requested' }); } catch (e) {}\n return _origGCP(success, error, options);\n };\n }\n } catch (e) {}\n true;\n})();\n";
44
export interface ChattyEmbedViewProps {
55
botId: string;
6+
/** Opens the standalone LiveKit voice surface instead of the normal chat surface. */
7+
voiceOnly?: boolean;
68
/** Overrides the embed page's own host (defaults to the production site). */
79
baseUrl?: string;
810
/** Called once the embed page has finished loading and is interactive (chatty:ready). */
@@ -25,6 +27,10 @@ export interface ChattyEmbedViewProps {
2527
/** Called when the visitor taps the attach menu's "Location" option and location permission
2628
* is not yet granted. Follows the same "reflect, never request" contract. */
2729
onLocationPermissionNeeded?: () => void;
30+
/** Optional override for the safe area top inset in points (e.g. status bar / notch height). */
31+
topInset?: number;
32+
/** Optional override for the safe area bottom inset in points (e.g. home indicator bar height). */
33+
bottomInset?: number;
2834
}
2935
/**
3036
* Loads the bot's own web widget page (the exact code chatty.personaliai.com

‎dist/ChattyEmbedView.js‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,20 @@ function OfflineView({ onRetry, bg, fg }) {
8484
* for zero parity drift; see ChattyChatView for the native-components version.
8585
*/
8686
export function ChattyEmbedView(props) {
87-
const { botId, baseUrl = chattyDefaultEmbedBaseUrl, onReady, onMessage, onClose, onRequestNotificationPermission, onMicPermissionNeeded, onLocationPermissionNeeded, } = props;
88-
const embedUrl = useMemo(() => `${baseUrl.replace(/\/$/, "")}/embed/${botId}`, [baseUrl, botId]);
87+
const { botId, voiceOnly = false, baseUrl = chattyDefaultEmbedBaseUrl, onReady, onMessage, onClose, onRequestNotificationPermission, onMicPermissionNeeded, onLocationPermissionNeeded, topInset, bottomInset, } = props;
88+
const embedUrl = useMemo(() => {
89+
const base = `${baseUrl.replace(/\/$/, "")}/embed/${botId}`;
90+
const params = new URLSearchParams();
91+
params.set("platform", "react-native");
92+
params.set("safeArea", "true");
93+
if (voiceOnly)
94+
params.set("voice", "only");
95+
if (typeof topInset === "number")
96+
params.set("topInset", String(Math.round(topInset)));
97+
if (typeof bottomInset === "number")
98+
params.set("bottomInset", String(Math.round(bottomInset)));
99+
return `${base}?${params.toString()}`;
100+
}, [baseUrl, botId, voiceOnly, topInset, bottomInset]);
89101
const embedOrigin = useMemo(() => new URL(embedUrl).origin, [embedUrl]);
90102
const webviewRef = useRef(null);
91103
const [pageReady, setPageReady] = useState(false);
@@ -137,6 +149,15 @@ export function ChattyEmbedView(props) {
137149
if (pageReady)
138150
deliver({ type: "chatty-notification-status", granted: notifGranted });
139151
}, [pageReady, notifGranted, deliver]);
152+
useEffect(() => {
153+
if (pageReady && (typeof topInset === "number" || typeof bottomInset === "number")) {
154+
deliver({
155+
type: "chatty-safe-area",
156+
top: topInset ?? 0,
157+
bottom: bottomInset ?? 0,
158+
});
159+
}
160+
}, [pageReady, topInset, bottomInset, deliver]);
140161
const handleWebViewMessage = useCallback((event) => {
141162
let msg = null;
142163
try {

‎dist/ChattyVoiceView.d.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import React from "react";
2+
import { ChattyEmbedViewProps } from "./ChattyEmbedView";
3+
/** Standalone LiveKit voice-agent UI backed by the official Chatty embed. */
4+
export declare function ChattyVoiceView(props: Omit<ChattyEmbedViewProps, "voiceOnly">): React.JSX.Element;

‎dist/ChattyVoiceView.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from "react";
2+
import { ChattyEmbedView } from "./ChattyEmbedView";
3+
/** Standalone LiveKit voice-agent UI backed by the official Chatty embed. */
4+
export function ChattyVoiceView(props) {
5+
return <ChattyEmbedView {...props} voiceOnly/>;
6+
}

‎dist/api.d.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ export interface ChattyTheme {
4545
* the text (poll-sourced messages are always human — see useChattyChat). */
4646
show_sender_tag?: boolean;
4747
color_scheme?: ChattyColorScheme;
48+
voice_enabled?: boolean;
49+
}
50+
export interface ChattyVoiceToken {
51+
serverUrl: string;
52+
participantToken: string;
53+
roomName: string;
54+
participantName: string;
4855
}
4956
export interface ChattyChatResponse {
5057
reply: string;
@@ -106,6 +113,12 @@ export declare class ChattyClient {
106113
name: string;
107114
type: string;
108115
}): Promise<string>;
116+
/**
117+
* Create a short-lived LiveKit participant token. API keys and provider
118+
* credentials never leave Chatty's backend. Connect the returned values with
119+
* the official `@livekit/react-native` client in the host application.
120+
*/
121+
createVoiceToken(sessionId: string, participantName?: string, visitorTimezone?: string): Promise<ChattyVoiceToken>;
109122
/** Poll for new messages (e.g. from a human agent) since the given ISO timestamp. */
110123
poll(sessionId: string, after: string): Promise<ChattyPollResponse>;
111124
}

‎dist/api.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,30 @@ export class ChattyClient {
146146
const data = await res.json();
147147
return data.text ?? "";
148148
}
149+
/**
150+
* Create a short-lived LiveKit participant token. API keys and provider
151+
* credentials never leave Chatty's backend. Connect the returned values with
152+
* the official `@livekit/react-native` client in the host application.
153+
*/
154+
async createVoiceToken(sessionId, participantName = "Visitor", visitorTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
155+
const res = await fetch(buildUrl(this.baseUrl, "/api/widget/voice/token", {}), {
156+
method: "POST",
157+
headers: { "Content-Type": "application/json" },
158+
body: JSON.stringify({
159+
bot_id: this.botId,
160+
session_id: sessionId,
161+
participant_name: participantName,
162+
visitor_timezone: visitorTimezone,
163+
}),
164+
});
165+
if (res.status === 429)
166+
throw new ChattyRateLimitError();
167+
if (res.status === 403)
168+
throw new ChattyDomainNotAllowedError();
169+
if (!res.ok)
170+
throw new Error(`createVoiceToken failed: ${res.status}`);
171+
return res.json();
172+
}
149173
/** Poll for new messages (e.g. from a human agent) since the given ISO timestamp. */
150174
async poll(sessionId, after) {
151175
const url = buildUrl(this.baseUrl, "/api/widget/poll", {

‎dist/index.d.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { ChattyClient, DEFAULT_BASE_URL, ChattyRateLimitError, ChattyDomainNotAllowedError } from "./api";
2-
export type { ChattyTheme, ChattyColorScheme, ChattyChatResponse, ChattyMediaResponse, ChattyPollMessage, ChattyPollResponse, ChattyClientOptions, } from "./api";
2+
export type { ChattyTheme, ChattyColorScheme, ChattyChatResponse, ChattyMediaResponse, ChattyPollMessage, ChattyPollResponse, ChattyClientOptions, ChattyVoiceToken, } from "./api";
33
export { useChattyChat } from "./useChattyChat";
44
export type { ChattyMessage, ChattyRole, UseChattyChatOptions, UseChattyChatResult } from "./useChattyChat";
55
export { ChattyChatView } from "./ChattyChatView";
@@ -8,6 +8,7 @@ export { ChattyLauncher } from "./ChattyLauncher";
88
export type { ChattyLauncherProps } from "./ChattyLauncher";
99
export { ChattyEmbedView, chattyDefaultEmbedBaseUrl } from "./ChattyEmbedView";
1010
export type { ChattyEmbedViewProps } from "./ChattyEmbedView";
11+
export { ChattyVoiceView } from "./ChattyVoiceView";
1112
export { getOrCreateSessionId, newSession } from "./session";
1213
export { CHATTY_DESIGN_TOKENS, chattyNormalizeWidgetStyle, chattyLogoBgColor, chattyLauncherRadii, chattyBubbleRadii } from "./designTokens";
1314
export type { ChattyDesignTokens } from "./designTokens";

‎dist/index.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { ChattyLauncher } from "./ChattyLauncher";
66
// guaranteeing exact parity with it. See ChattyChatView above for the
77
// native-components alternative (more native feel, manually kept in parity).
88
export { ChattyEmbedView, chattyDefaultEmbedBaseUrl } from "./ChattyEmbedView";
9+
export { ChattyVoiceView } from "./ChattyVoiceView";
910
export { getOrCreateSessionId, newSession } from "./session";
1011
export { CHATTY_DESIGN_TOKENS, chattyNormalizeWidgetStyle, chattyLogoBgColor, chattyLauncherRadii, chattyBubbleRadii } from "./designTokens";
1112
export { ChattyMarkdown, extractMath, formatLatexForDisplay, parseInlineTokens } from "./ChattyMarkdown";

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@personaliai/react-native",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "Official React Native SDK for Chatty AI chatbots — loads the web widget in a WebView for guaranteed parity, with native components as an alternative",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

‎src/ChattyEmbedView.tsx‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ export interface ChattyEmbedViewProps {
129129
/** Called when the visitor taps the attach menu's "Location" option and location permission
130130
* is not yet granted. Follows the same "reflect, never request" contract. */
131131
onLocationPermissionNeeded?: () => void;
132+
/** Optional override for the safe area top inset in points (e.g. status bar / notch height). */
133+
topInset?: number;
134+
/** Optional override for the safe area bottom inset in points (e.g. home indicator bar height). */
135+
bottomInset?: number;
132136
}
133137

134138
function OfflineView({ onRetry, bg, fg }: { onRetry: () => void; bg: string; fg: string }) {
@@ -161,11 +165,19 @@ export function ChattyEmbedView(props: ChattyEmbedViewProps) {
161165
onRequestNotificationPermission,
162166
onMicPermissionNeeded,
163167
onLocationPermissionNeeded,
168+
topInset,
169+
bottomInset,
164170
} = props;
165171
const embedUrl = useMemo(() => {
166-
const url = `${baseUrl.replace(/\/$/, "")}/embed/${botId}`;
167-
return voiceOnly ? `${url}?voice=only` : url;
168-
}, [baseUrl, botId, voiceOnly]);
172+
const base = `${baseUrl.replace(/\/$/, "")}/embed/${botId}`;
173+
const params = new URLSearchParams();
174+
params.set("platform", "react-native");
175+
params.set("safeArea", "true");
176+
if (voiceOnly) params.set("voice", "only");
177+
if (typeof topInset === "number") params.set("topInset", String(Math.round(topInset)));
178+
if (typeof bottomInset === "number") params.set("bottomInset", String(Math.round(bottomInset)));
179+
return `${base}?${params.toString()}`;
180+
}, [baseUrl, botId, voiceOnly, topInset, bottomInset]);
169181
const embedOrigin = useMemo(() => new URL(embedUrl).origin, [embedUrl]);
170182

171183
const webviewRef = useRef<WebView>(null);
@@ -222,6 +234,16 @@ export function ChattyEmbedView(props: ChattyEmbedViewProps) {
222234
if (pageReady) deliver({ type: "chatty-notification-status", granted: notifGranted });
223235
}, [pageReady, notifGranted, deliver]);
224236

237+
useEffect(() => {
238+
if (pageReady && (typeof topInset === "number" || typeof bottomInset === "number")) {
239+
deliver({
240+
type: "chatty-safe-area",
241+
top: topInset ?? 0,
242+
bottom: bottomInset ?? 0,
243+
});
244+
}
245+
}, [pageReady, topInset, bottomInset, deliver]);
246+
225247
const handleWebViewMessage = useCallback(
226248
(event: WebViewMessageEvent) => {
227249
let msg: OutboundBridgeMessage | null = null;

0 commit comments

Comments
 (0)