WebView recipes: React Native, iOS and Android
Copy-paste screens that open the Gauge support chat in your app. Each one does the same five things as the Flutter package (gauge_support_webview):
- asks your backend for a fresh single-use link on every open (
POST /v1/support/sessions,target: "chat"; any quick start has the endpoint), and addsembed=1; - keeps navigation on your portal host and opens everything else in the system browser;
- grants the microphone (in-app calls) and camera only to your portal host;
- routes
<input type="file">to the native picker and keeps the composer above the keyboard; - signs out by loading
/logout?embed=1in the same WebView and closing on/logout/done.
No Gauge key or secret is in any of them. Background and the reasons behind each rule: mobile.md.
Replace https://api.yourapp.com/support/chat-link with your own endpoint, and send your own auth.
React Native (react-native-webview ≥ 13)
// SupportChat.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Linking, PermissionsAndroid, Platform, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import WebView, { type WebViewNavigation } from 'react-native-webview';
import type { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes';
async function fetchChatLink(appToken: string): Promise<string> {
const res = await fetch('https://api.yourapp.com/support/chat-link', {
headers: { authorization: `Bearer ${appToken}` },
});
if (!res.ok) throw new Error(`chat link: ${res.status}`);
const { url } = (await res.json()) as { url: string };
const link = new URL(url);
if (link.protocol !== 'https:') throw new Error('chat link must be https');
return url + (link.search ? '&' : '?') + 'embed=1'; // appended as text: the token stays byte-exact
}
export function SupportChat({ appToken, onSignedOut, signOutRequested }: {
appToken: string;
onSignedOut: () => void;
/** Flip to true when the user signs out of your app while this screen is open. */
signOutRequested?: boolean;
}) {
const [link, setLink] = useState<string | null>(null);
const [error, setError] = useState(false);
const web = useRef<WebView>(null);
const portalHost = link ? new URL(link).host : null;
const open = useCallback(async () => {
setError(false);
setLink(null);
try {
if (Platform.OS === 'android') {
// In-app calls need the microphone; the page asks only when the customer presses Call.
await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);
}
setLink(await fetchChatLink(appToken)); // fresh on every open: a link is single-use
} catch {
setError(true);
}
}, [appToken]);
useEffect(() => { void open(); }, [open]);
useEffect(() => {
if (signOutRequested && portalHost) {
web.current?.injectJavaScript(`location.assign('https://${portalHost}/logout?embed=1'); true;`);
}
}, [signOutRequested, portalHost]);
const onShouldStart = (req: ShouldStartLoadRequest) => {
const url = new URL(req.url);
if (url.protocol === 'https:' && url.host === portalHost) return true;
if (url.protocol === 'https:' || url.protocol === 'mailto:' || url.protocol === 'tel:') {
if (req.isTopFrame) void Linking.openURL(req.url);
}
return false; // javascript:, intent:, file:, http: never load in the chat
};
const onNav = (nav: WebViewNavigation) => {
if (portalHost && new URL(nav.url).host === portalHost && new URL(nav.url).pathname === '/logout/done') {
onSignedOut();
}
};
if (error) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<Text>Support chat didn't open.</Text>
<Pressable onPress={open}><Text style={{ fontWeight: '600' }}>Try again</Text></Pressable>
</View>
);
}
if (!link) return <ActivityIndicator style={{ flex: 1 }} />;
return (
<SafeAreaView style={{ flex: 1 }} edges={['bottom', 'left', 'right']}>
<WebView
ref={web}
source={{ uri: link }}
originWhitelist={['https://*']}
onShouldStartLoadWithRequest={onShouldStart}
onNavigationStateChange={onNav}
javaScriptEnabled
domStorageEnabled
thirdPartyCookiesEnabled={false}
sharedCookiesEnabled={false}
setSupportMultipleWindows={false}
allowsInlineMediaPlayback
mediaPlaybackRequiresUserGesture={false}
mediaCapturePermissionGrantType="grantIfSameHostElsePrompt"
allowFileAccess={false}
onError={() => setError(true)}
/>
</SafeAreaView>
);
}- Android manifest:
RECORD_AUDIO,MODIFY_AUDIO_SETTINGS,CAMERA(if the picker offers it), andandroid:windowSoftInputMode="adjustResize". Don't wrap the WebView in aKeyboardAvoidingView. - iOS
Info.plist:NSMicrophoneUsageDescription,NSCameraUsageDescription,NSPhotoLibraryUsageDescription. react-native-webviewgrants the page's microphone request on Android only when your app holdsRECORD_AUDIO;grantIfSameHostElsePromptdoes the same on iOS 15+.- File inputs open the native picker on both platforms.
iOS (WKWebView, Swift, iOS 15+)
// SupportChatViewController.swift
import UIKit
import WebKit
final class SupportChatViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
/// YOUR backend: returns { "url": "https://<portal>/auth/handoff?token=…" } for the signed-in user.
var fetchChatLink: () async throws -> URL = { throw URLError(.userAuthenticationRequired) }
var onSignedOut: () -> Void = {}
private var webView: WKWebView!
private var portalHost: String?
override func viewDidLoad() {
super.viewDidLoad()
title = "Support"
let config = WKWebViewConfiguration()
config.websiteDataStore = .default() // keeps the chat session across launches
config.allowsInlineMediaPlayback = true // the agent's call audio plays in place
config.mediaTypesRequiringUserActionForPlayback = []
webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = self
webView.uiDelegate = self
webView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
NSLayoutConstraint.activate([ // inside the safe area: nothing under the home indicator
webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
webView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
open()
}
func open() {
Task { @MainActor in
do {
let minted = try await fetchChatLink() // fresh on every open: a link is single-use
guard minted.scheme == "https", let host = minted.host else { throw URLError(.badURL) }
portalHost = host
var text = minted.absoluteString // appended as text: the token stays byte-exact
text += (minted.query == nil ? "?" : "&") + "embed=1"
webView.load(URLRequest(url: URL(string: text)!))
} catch {
showRetry()
}
}
}
/// Call when the user signs out of your app with this screen open.
func signOut() {
guard let host = portalHost else { return }
webView.load(URLRequest(url: URL(string: "https://\(host)/logout?embed=1")!))
}
// Navigation: your portal host only; the rest goes to the system.
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url else { return decisionHandler(.cancel) }
if url.scheme == "https", url.host == portalHost {
if url.path == "/logout/done" { finishSignOut() }
return decisionHandler(.allow)
}
if ["https", "mailto", "tel"].contains(url.scheme ?? ""), action.targetFrame?.isMainFrame != false {
UIApplication.shared.open(url)
}
decisionHandler(.cancel)
}
// target=_blank links: never a second WebView.
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if let url = action.request.url { UIApplication.shared.open(url) }
return nil
}
// Microphone (in-app calls) and camera: your portal host only.
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
decisionHandler(origin.protocol == "https" && origin.host == portalHost ? .grant : .deny)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
showRetry()
}
private func finishSignOut() {
WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: .distantPast) { [weak self] in
self?.onSignedOut()
}
}
private func showRetry() {
let alert = UIAlertController(title: "Support chat didn't open",
message: "Check your connection and try again.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Try again", style: .default) { [weak self] _ in self?.open() })
present(alert, animated: true)
}
}Info.plist: NSMicrophoneUsageDescription, NSCameraUsageDescription, NSPhotoLibraryUsageDescription.
<input type="file"> presents the system picker by itself. No WKScriptMessageHandler is added:
the page needs no bridge into your app.
Android (WebView, Kotlin)
// SupportChatActivity.kt
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.webkit.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
class SupportChatActivity : AppCompatActivity() {
private lateinit var web: WebView
private var portalHost: String? = null
private var fileCallback: ValueCallback<Array<Uri>>? = null
private val pickFile = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
fileCallback?.onReceiveValue(if (uri == null) emptyArray() else arrayOf(uri))
fileCallback = null
}
private val askMic = registerForActivityResult(ActivityResultContracts.RequestPermission()) { open() }
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
web = WebView(this)
setContentView(web) // manifest: android:windowSoftInputMode="adjustResize"
with(web.settings) {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
allowFileAccess = false
allowContentAccess = true // the picker hands back content:// URIs
setSupportMultipleWindows(false)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(web, false)
web.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
if (url.scheme == "https" && url.host == portalHost) {
if (url.path == "/logout/done") finishSignOut()
return false
}
if (request.isForMainFrame && url.scheme in setOf("https", "mailto", "tel")) {
startActivity(Intent(Intent.ACTION_VIEW, url))
}
return true // javascript:, intent:, file:, http: never load in the chat
}
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
if (request.isForMainFrame) showRetry()
}
}
web.webChromeClient = object : WebChromeClient() {
// Microphone (in-app calls) and camera: your portal host only, nothing else.
override fun onPermissionRequest(request: PermissionRequest) {
val allowed = setOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE, PermissionRequest.RESOURCE_VIDEO_CAPTURE)
val fromPortal = request.origin.scheme == "https" && request.origin.host == portalHost
if (fromPortal && request.resources.all { it in allowed }) request.grant(request.resources)
else request.deny()
}
override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>,
params: FileChooserParams): Boolean {
fileCallback?.onReceiveValue(null)
fileCallback = callback
pickFile.launch(params.acceptTypes.firstOrNull { it.isNotBlank() } ?: "*/*")
return true
}
}
askMic.launch(Manifest.permission.RECORD_AUDIO) // the page asks only when the customer presses Call
}
private fun open() = lifecycleScope.launch {
try {
val minted = Uri.parse(fetchChatLink()) // YOUR backend; fresh on every open
require(minted.scheme == "https" && minted.host != null)
portalHost = minted.host
val text = minted.toString() // appended as text: the token stays byte-exact
web.loadUrl(text + (if (minted.query == null) "?" else "&") + "embed=1")
} catch (e: Exception) {
showRetry()
}
}
/** Call when the user signs out of your app with this screen open. */
fun signOut() {
portalHost?.let { web.loadUrl("https://$it/logout?embed=1") }
}
private fun finishSignOut() {
CookieManager.getInstance().removeAllCookies(null)
WebStorage.getInstance().deleteAllData()
finish()
}
private fun showRetry() {
AlertDialog.Builder(this)
.setTitle("Support chat didn't open")
.setMessage("Check your connection and try again.")
.setPositiveButton("Try again") { _, _ -> open() }
.show()
}
/** YOUR backend call (OkHttp, Ktor, Retrofit…): GET /support/chat-link with your app's auth → "url". */
private suspend fun fetchChatLink(): String = TODO("call your backend")
}Manifest: INTERNET, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, CAMERA (if the picker should offer it),
and android:windowSoftInputMode="adjustResize" on the activity. No addJavascriptInterface: the
page needs no bridge into your app. Edge-to-edge apps: pad the WebView by the system-bar insets
(ViewCompat.setOnApplyWindowInsetsListener) so the composer never sits under the navigation bar.
Check it
Open the screen as a test user, send a message, and confirm in the console that the conversation
shows your user's name. Then sign out: the screen should close on /logout/done, and opening it
again should mint a new link (one link never opens twice).
Support in a native app: live chat in a WebView, and push on reply
This page is for a tenant whose product is a native app: Salonvira's two Flutter apps are the first. It covers two jobs.
Support embed S1: the API core
This page is the reference for the console UI lane and for integrators. Machine truth lives in GET /openapi.json, and the zod sources are in packages/types/src/support-embed.ts,…