iOS and Android
Embed the board in a WKWebView or Android WebView with SSO.
TheyAsked runs inside your iOS, Android, React Native or Flutter app as a WebView pointed at the embed surface. That is what Canny and Featurebase ship too: one code path, always up to date, no SDK release when the board changes. Your app supplies the identity (an SSO token from your backend), so people never sign in twice, and every vote and post is tagged with the platform.
The embed URL
https://theyasked.me/embed/<workspace>?platform=ios&token=<sso-jwt>
| Param | Values | |
|---|---|---|
platform |
ios · android · web |
Recorded as the source of posts, votes and comments (ios / android), and on the person. |
token |
SSO JWT | Signed by your backend (SSO.md). Identified in the background; stripped from history. |
theme |
light · dark · auto |
Match your app. Omit to use the workspace's setting (or the system). |
accent, font, radius |
hex, font id, 0/4/6/10/16 | Per-app theme tweaks (THEMING.md). |
tab |
feedback · roadmap · changelog |
Start tab. changelog is the "What's new" view. |
board |
board slug | Default board for the feed and new posts. |
post |
post slug | Open a request directly (deep links). |
list |
list key | Show a Vote list (integrations) instead of the board: logo tiles, Vote buttons and “Request another…”. No feed header; your ✕ closes it. |
hideHeader |
1 |
Hide the embed header when your native nav bar shows the title. |
close |
1 / 0 |
Show / hide the embed's close button (emits theyasked:close). |
locale |
e.g. ar |
Sets the document language. |
The page is designed at 360–390 px: 44 px touch targets, safe-area insets (viewport-fit=cover),
bottom-sheet composer, sticky "Ask for something" above the home indicator. Sessions are bearer tokens
held in the page (memory + sessionStorage); third-party cookies are never needed.
Send the user's group
Put the user's account, family, team or class in the token as company (alias group) so requests
count once per group and a shared subscription isn't counted once per member. A family app, parent
and kid in the same family (the kid has no email):
{ "id": "u_54", "email": "ana@example.com", "name": "Ana", "attributes": { "role": "parent" },
"company": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 } }
{ "id": "u_55", "name": "Maya", "attributes": { "role": "kid", "ageBand": "9-12" },
"company": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 } }
More models (B2B, stores, schools, clans) and the revenue rules: SSO.md. Using an AI assistant? Point it at https://theyasked.me/llms.txt.
Recommended presentation: a modal sheet
Open the board as a modal (bottom sheet / full-screen modal), not as a pushed screen.
- Your app owns one control: the ✕ (close) on the sheet's header, which dismisses the whole board.
- The board owns navigation inside it: with
hideHeader=1it shows "← All requests" on request pages. - A pushed screen gives users two back arrows (your navigation bar's and ours) that do different things, which is confusing. A modal avoids that: ✕ always means "close feedback", ← always means "back to the list".
Recommended setup:
- iOS:
.sheet/.fullScreenCover(SwiftUI) orUISheetPresentationControllerwith a large detent; a header with your title + ✕. - Android:
ModalBottomSheet(Compose) or a full-screen dialog; a header with title + ✕. Map the system back toTheyAskedEmbed.back()whilecanGoBackis true, otherwise dismiss. - React Native / Flutter: a modal route or bottom sheet with a ✕ header.
- URL:
/embed/<workspace>?platform=ios|android&hideHeader=1&token=<sso>. - Listen for
theyasked:close(sent whenbackis called on the list) to dismiss the sheet.
Only use a pushed screen if your app already hides its own navigation bar for it; then leave hideHeader off so the board shows its own header with Back.
The bridge
The embed posts the same JSON object to every host it can find:
| Host | Channel the embed calls |
|---|---|
iOS WKWebView |
window.webkit.messageHandlers.theyasked.postMessage(msg) (object) |
Android WebView |
window.TheyAskedBridge.postMessage(json) (@JavascriptInterface) |
| React Native | window.ReactNativeWebView.postMessage(json) |
Flutter webview_flutter |
TheyAskedBridge.postMessage(json) (JavaScript channel) |
{ "source": "theyasked-embed", "type": "theyasked:post_created", "postId": "pst_…", "slug": "dark-mode", "title": "Dark mode", "pending": false }
type |
Payload | Typical reaction |
|---|---|---|
theyasked:ready |
workspace, platform, tabs (+ list for a Vote list) |
Hide your spinner |
theyasked:identified |
id, kind, name |
|
theyasked:token_expired |
reason |
Fetch a fresh token from your backend and push it (below) |
theyasked:identify_failed |
reason |
Log it; the board keeps working anonymously if allowed |
theyasked:post_created |
postId, slug, title, pending |
Analytics, a thank-you |
theyasked:voted |
postId, voted, voteCount (+ listKey in a Vote list) |
Analytics |
theyasked:requested |
listKey, postId, title, matched, pending |
A Vote list's “Request another…” (matched: already requested, the vote went there) |
theyasked:changelog_seen |
at (unix ms) |
Clear your own "What's new" badge |
theyasked:navigate |
tab, post |
Update your nav title |
theyasked:close |
Dismiss the screen | |
theyasked:resize |
height |
Only useful for self-sizing containers |
theyasked:open_url |
url (https) |
Open it in the system browser (below) |
Commands into the page: evaluate window.TheyAskedEmbed.identify("<token>"), window.TheyAskedEmbed.logout(),
window.TheyAskedEmbed.open("changelog") / window.TheyAskedEmbed.open(null, "<post-slug>") or
window.TheyAskedEmbed.theme({ mode: "dark" }). (A
postMessage({ source: 'theyasked-host', type: 'identify', token }) works as well, and so does
{ source: 'theyasked-host', type: 'theme', theme: { mode: 'dark' } }.)
Follow your app's dark mode
Load the embed with the app's current mode (theme=dark|light), then push changes while the screen
is open; nothing reloads. Colours, fonts and corners come from Settings → Appearance
(THEMING.md), including "Hide the embed header in mobile apps".
// SwiftUI: in your UIViewRepresentable
func updateUIView(_ web: WKWebView, context: Context) {
let mode = context.environment.colorScheme == .dark ? "dark" : "light"
web.evaluateJavaScript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '\(mode)' })")
}
// Compose: re-run when the system theme flips
val dark = isSystemInDarkTheme()
LaunchedEffect(dark) {
web?.evaluateJavascript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${if (dark) "dark" else "light"}' })", null)
}
// React Native
const scheme = useColorScheme();
useEffect(() => {
ref.current?.injectJavaScript(`window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${scheme ?? 'light'}' }); true;`);
}, [scheme]);
// Flutter: e.g. in didChangePlatformBrightness()
final dark = WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark;
_controller.runJavaScript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${dark ? 'dark' : 'light'}' })");
Payloads never contain session tokens or email addresses.
Compatibility with Loop-era apps
Apps built before the rename keep working without a release:
webkit.messageHandlers.loopandwindow.LoopBridgestill receive every event, in the old shape ({ "source": "loop-embed", "type": "loop:close", … }). The new channels get the new shape.- React Native and the widget iframe have a single channel, so they receive both shapes, new
first. Filter on
source(the snippets above do). window.LoopEmbedis an alias ofwindow.TheyAskedEmbed, and commands sent withsource: 'loop-host'or'loop-widget'are accepted.
When you next ship, register the theyasked handler / TheyAskedBridge channel instead of the
old one (registering both makes the events arrive twice).
External links (theyasked:open_url)
The embed shows a small "Powered by theyasked.me" badge at the bottom of each tab (mandatory,
THEMING.md). With platform=ios|android and a native channel present, tapping
it does not navigate the WebView: the embed posts
{ "source": "theyasked-embed", "type": "theyasked:open_url", "url": "https://theyasked.me/?ref=…" }
(loop:open_url on the Loop-era channels). Open url in the system browser:
case "theyasked:open_url":
if let s = msg["url"] as? String, let url = URL(string: s), url.scheme == "https" {
UIApplication.shared.open(url)
}
"theyasked:open_url" -> msg.optString("url").takeIf { it.startsWith("https://") }?.let {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it)))
}
React Native: Linking.openURL(msg.url); Flutter: launchUrl(Uri.parse(msg['url']), mode: LaunchMode.externalApplication) (url_launcher). Without a native channel the link opens
normally (target="_blank"), so apps that don't handle the event still work; adding the
handler only keeps the user out of an in-app page.
Back button: list ↔ request page
With hideHeader=1 your app draws the top bar, so the embed shows an in-page "← All requests" link on request pages. To make your own back button (and Android's back / iOS swipe) behave like a browser instead of closing the screen:
- Listen for
theyasked:navigate; its payload hascanGoBack: truewhile a request page is open. - When
canGoBackis true, send the embed back instead of popping your screen:
// any host: postMessage
webview.postMessage(JSON.stringify({ source: 'theyasked-host', type: 'back' }));
// or evaluate JS (iOS evaluateJavaScript / Android evaluateJavascript / Flutter runJavaScript)
window.TheyAskedEmbed.back();
- If
backarrives while the list is showing, the embed emitstheyasked:close, so you can pop your screen. - A Vote list (
?list=) is always the root view:backemitstheyasked:closeandcanGoBackis never true.
Swift:
if canGoBack { webView.evaluateJavaScript("window.TheyAskedEmbed.back()") } else { dismiss(animated: true) }
Kotlin (Compose BackHandler):
BackHandler(enabled = canGoBack) { webView.evaluateJavascript("window.TheyAskedEmbed.back()", null) }
React Native: in your onMessage, track canGoBack and call webviewRef.current?.injectJavaScript('window.TheyAskedEmbed.back(); true;') from your header/BackHandler.
Flutter: in PopScope(canPop: !canGoBack, onPopInvoked: (_) => controller.runJavaScript('window.TheyAskedEmbed.back()')).
Token refresh
Mint tokens with exp ≈ 1 hour. If the screen stays open longer, or the user switches account:
- The embed emits
theyasked:token_expiredwhen a token is rejected as expired. - Your app asks its backend for a new token.
- It calls
window.TheyAskedEmbed.identify(newToken). The session continues; nothing reloads.
On sign-out in your app call window.TheyAskedEmbed.logout() (or just discard the WebView).
"What's new" badge outside the WebView
GET /api/v1/w/<workspace>/changelog/unread?since=<unix ms> → { "count": 2, "latest": { … } }.
Store the at from theyasked:changelog_seen and pass it as since.
iOS: WKWebView + SwiftUI
import SwiftUI
import WebKit
struct TheyAskedFeedbackView: UIViewRepresentable {
let workspace: String
let fetchToken: () async throws -> String // calls YOUR backend
var tab: String = "feedback"
var onClose: () -> Void = {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.userContentController.add(context.coordinator, name: "theyasked")
let web = WKWebView(frame: .zero, configuration: config)
web.isOpaque = false
web.scrollView.contentInsetAdjustmentBehavior = .never // the page handles safe areas
context.coordinator.web = web
Task { await context.coordinator.load() }
return web
}
func updateUIView(_ web: WKWebView, context: Context) {}
static func dismantleUIView(_ web: WKWebView, coordinator: Coordinator) {
web.configuration.userContentController.removeScriptMessageHandler(forName: "theyasked")
}
final class Coordinator: NSObject, WKScriptMessageHandler {
let parent: TheyAskedFeedbackView
weak var web: WKWebView?
init(_ parent: TheyAskedFeedbackView) { self.parent = parent }
@MainActor func load() async {
let token = (try? await parent.fetchToken()) ?? ""
var c = URLComponents(string: "https://theyasked.me/embed/\(parent.workspace)")!
c.queryItems = [
.init(name: "platform", value: "ios"),
.init(name: "tab", value: parent.tab),
.init(name: "close", value: "1"),
.init(name: "token", value: token),
]
web?.load(URLRequest(url: c.url!))
}
func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) {
guard let msg = message.body as? [String: Any], let type = msg["type"] as? String else { return }
switch type {
case "theyasked:close":
parent.onClose()
case "theyasked:token_expired":
Task { @MainActor in
guard let token = try? await parent.fetchToken() else { return }
let js = "window.TheyAskedEmbed && window.TheyAskedEmbed.identify(\(Self.jsString(token)))"
_ = try? await web?.evaluateJavaScript(js)
}
case "theyasked:post_created":
print("TheyAsked: new idea", msg["title"] ?? "")
default: break
}
}
static func jsString(_ s: String) -> String {
let data = try! JSONSerialization.data(withJSONObject: [s])
return String(String(data: data, encoding: .utf8)!.dropFirst().dropLast())
}
}
}
// Usage
struct SettingsScreen: View {
@State private var showFeedback = false
var body: some View {
Button("Feedback") { showFeedback = true }
.sheet(isPresented: $showFeedback) {
TheyAskedFeedbackView(workspace: "acme", fetchToken: Api.shared.theyaskedToken) { showFeedback = false }
.ignoresSafeArea()
}
}
}
UIKit: the coordinator above works unchanged with a plain WKWebView in a view controller.
Android: WebView + Jetpack Compose
class TheyAskedBridge(private val onMessage: (JSONObject) -> Unit) {
@JavascriptInterface
fun postMessage(json: String) = onMessage(JSONObject(json))
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun TheyAskedFeedbackScreen(
workspace: String,
fetchToken: suspend () -> String, // calls YOUR backend
onClose: () -> Unit,
) {
val scope = rememberCoroutineScope()
var web by remember { mutableStateOf<WebView?>(null) }
var initialToken by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { initialToken = runCatching { fetchToken() }.getOrDefault("") }
BackHandler(enabled = web?.canGoBack() == true) { web?.goBack() } // in-embed navigation
val token = initialToken ?: return
AndroidView(
modifier = Modifier.fillMaxSize().systemBarsPadding(),
factory = { ctx ->
WebView(ctx).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true // sessionStorage for the TheyAsked session
addJavascriptInterface(TheyAskedBridge { msg ->
post {
when (msg.optString("type")) {
"theyasked:close" -> onClose()
"theyasked:token_expired" -> scope.launch {
val fresh = fetchToken()
evaluateJavascript("window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${JSONObject.quote(fresh)})", null)
}
}
}
}, "TheyAskedBridge")
val url = Uri.parse("https://theyasked.me/embed/$workspace").buildUpon()
.appendQueryParameter("platform", "android")
.appendQueryParameter("close", "1")
.appendQueryParameter("token", token)
.build().toString()
loadUrl(url)
web = this
}
},
)
}
Only expose TheyAskedBridge to your TheyAsked origin: keep navigation inside the WebView limited to it (a
WebViewClient.shouldOverrideUrlLoading that opens other hosts in the browser).
React Native: react-native-webview
import { useCallback, useRef } from 'react';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
export function TheyAskedFeedback({ workspace, token, fetchToken, onClose }: {
workspace: string;
token: string; // from YOUR backend
fetchToken: () => Promise<string>;
onClose: () => void;
}) {
const ref = useRef<WebView>(null);
const uri = `https://theyasked.me/embed/${workspace}?platform=${Platform.OS}&close=1&token=${encodeURIComponent(token)}`;
const onMessage = useCallback(async (e: WebViewMessageEvent) => {
const msg = JSON.parse(e.nativeEvent.data);
if (msg.source !== 'theyasked-embed') return;
if (msg.type === 'theyasked:close') onClose();
if (msg.type === 'theyasked:token_expired') {
const fresh = await fetchToken();
ref.current?.injectJavaScript(`window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${JSON.stringify(fresh)}); true;`);
}
}, [fetchToken, onClose]);
return (
<WebView
ref={ref}
source={{ uri }}
onMessage={onMessage}
originWhitelist={['https://theyasked.me']}
setSupportMultipleWindows={false}
contentInsetAdjustmentBehavior="never"
style={{ flex: 1 }}
/>
);
}
(import { Platform } from 'react-native'.) Expo: identical with react-native-webview from Expo.
Flutter: webview_flutter
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class TheyAskedFeedback extends StatefulWidget {
const TheyAskedFeedback({super.key, required this.workspace, required this.fetchToken});
final String workspace;
final Future<String> Function() fetchToken; // calls YOUR backend
@override
State<TheyAskedFeedback> createState() => _TheyAskedFeedbackState();
}
class _TheyAskedFeedbackState extends State<TheyAskedFeedback> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('TheyAskedBridge', onMessageReceived: (m) async {
final msg = jsonDecode(m.message) as Map<String, dynamic>;
switch (msg['type']) {
case 'theyasked:close':
if (mounted) Navigator.of(context).pop();
case 'theyasked:token_expired':
final fresh = await widget.fetchToken();
await _controller.runJavaScript('window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${jsonEncode(fresh)})');
}
});
_load();
}
Future<void> _load() async {
final token = await widget.fetchToken();
final uri = Uri.https('theyasked.me', '/embed/${widget.workspace}', {
'platform': Platform.isIOS ? 'ios' : 'android',
'close': '1',
'token': token,
});
await _controller.loadRequest(uri);
}
@override
Widget build(BuildContext context) => Scaffold(body: WebViewWidget(controller: _controller));
}
Testing without an app
apps/widget/demo/mobile.html is a phone-frame simulator: it signs a token in the page with the dev
secret, loads the embed with platform=ios|android, logs every bridge event and has buttons for
"push fresh token", "push expired token" (exercises theyasked:token_expired → refresh) and "logout".
pnpm dev # TheyAsked on :5173 / :8787
cd apps/widget && python3 -m http.server 5199
open "http://127.0.0.1:5199/demo/mobile.html?host=http://localhost:5173"
Sign a token from the command line with npx tsx scripts/sso-token.mts --platform ios --urls 1.
Checklist
- Tokens are signed on your server,
exp≈ 1 h,platformset. -
theyasked:token_expiredfetches a fresh token and callsTheyAskedEmbed.identify. -
theyasked:open_urlopens the URL in the system browser. -
theyasked:closedismisses the screen; Android back navigates inside the embed first. -
domStorageEnabledon Android; WebView restricted to your TheyAsked origin. - Theme passed (
theme=dark) when your app is in dark mode, andTheyAskedEmbed.theme()called when it changes.