@selfhelp/sh2-shp-survey-js-mobile 0.3.0

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.
@@ -0,0 +1,166 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * SurveyJS WebView runtime controller.
7
+ *
8
+ * This is the DOM-free brain of the isolated WebView runtime: given a
9
+ * `survey-core` model and the typed bridge `post`, it owns the survey
10
+ * lifecycle WITHOUT touching the network. It emits intents
11
+ * (`SAVE_PROGRESS` / `SUBMIT_SURVEY` / `REQUEST_REDIRECT`) and reacts to the
12
+ * results the native host returns (`SUBMIT_RESULT` / `SESSION_EXPIRED` /
13
+ * `PROGRESS_SAVED` / `SET_LOCALE`).
14
+ *
15
+ * Keeping it framework- and DOM-free means the unit test drives a REAL
16
+ * `survey-core` model (fill -> validate -> complete) headlessly in Node and
17
+ * asserts the controller emits `SUBMIT_SURVEY` (never a `fetch`).
18
+ */
19
+
20
+ import type { THostToWebviewMessage, TWebviewToHostMessage } from '../bridge/messages';
21
+ import { BRIDGE_SOURCE } from '../bridge/messages';
22
+ import type { IRuntimeSectionConfig } from '../styles/section';
23
+ import { buildEnforcePayload, newResponseId } from './lifecycle';
24
+
25
+ export type TRuntimeLifecycle =
26
+ | 'booting'
27
+ | 'loading'
28
+ | 'ready'
29
+ | 'submitting'
30
+ | 'submitted'
31
+ | 'locked'
32
+ | 'session-expired'
33
+ | 'error';
34
+
35
+ export interface IRuntimeLifecycleDetail {
36
+ message?: string;
37
+ submittedAt?: string;
38
+ }
39
+
40
+ /** The slice of the `survey-core` `Model` the controller depends on. */
41
+ export interface ISurveyModelLike {
42
+ data: Record<string, unknown>;
43
+ currentPageNo: number;
44
+ locale: string;
45
+ onCurrentPageChanged: { add(cb: () => void): void };
46
+ onComplete: { add(cb: (sender: ISurveyModelLike) => void): void };
47
+ }
48
+
49
+ export interface IControllerOptions {
50
+ config: IRuntimeSectionConfig;
51
+ post: (msg: TWebviewToHostMessage) => void;
52
+ onLifecycle: (lifecycle: TRuntimeLifecycle, detail?: IRuntimeLifecycleDetail) => void;
53
+ /** Response id restored from a server draft, if any. */
54
+ initialResponseId?: string | null;
55
+ }
56
+
57
+ export interface ISurveyRuntimeController {
58
+ attachModel(model: ISurveyModelLike): void;
59
+ handleHostMessage(msg: THostToWebviewMessage): void;
60
+ getResponseId(): string | null;
61
+ getLifecycle(): TRuntimeLifecycle;
62
+ }
63
+
64
+ /** True for an absolute URL with a scheme (`https://`, `mailto:`-style schemes). */
65
+ export function isExternalRedirect(target: string): boolean {
66
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(target);
67
+ }
68
+
69
+ export function createSurveyRuntimeController(options: IControllerOptions): ISurveyRuntimeController {
70
+ const { config, post, onLifecycle } = options;
71
+ let responseId: string | null = options.initialResponseId ?? null;
72
+ let lifecycle: TRuntimeLifecycle = 'booting';
73
+ let model: ISurveyModelLike | null = null;
74
+
75
+ function setLifecycle(next: TRuntimeLifecycle, detail?: IRuntimeLifecycleDetail): void {
76
+ lifecycle = next;
77
+ onLifecycle(next, detail);
78
+ }
79
+
80
+ function ensureResponseId(): string {
81
+ if (responseId === null) responseId = newResponseId();
82
+ return responseId;
83
+ }
84
+
85
+ function attachModel(nextModel: ISurveyModelLike): void {
86
+ model = nextModel;
87
+
88
+ nextModel.onCurrentPageChanged.add(() => {
89
+ post({
90
+ source: BRIDGE_SOURCE,
91
+ type: 'SAVE_PROGRESS',
92
+ responseId: ensureResponseId(),
93
+ pageNo: nextModel.currentPageNo,
94
+ data: nextModel.data,
95
+ locale: nextModel.locale,
96
+ });
97
+ });
98
+
99
+ nextModel.onComplete.add((sender) => {
100
+ setLifecycle('submitting');
101
+ const enforce = buildEnforcePayload(config, responseId, sender.currentPageNo);
102
+ post({
103
+ source: BRIDGE_SOURCE,
104
+ type: 'SUBMIT_SURVEY',
105
+ responseId,
106
+ data: sender.data,
107
+ enforce,
108
+ });
109
+ });
110
+
111
+ setLifecycle('ready');
112
+ }
113
+
114
+ function handleSubmitResult(msg: Extract<THostToWebviewMessage, { type: 'SUBMIT_RESULT' }>): void {
115
+ if (msg.ok) {
116
+ responseId = msg.responseId;
117
+ if (config.oncePerUser || config.oncePerSchedule) {
118
+ setLifecycle('locked');
119
+ } else {
120
+ setLifecycle('submitted', { submittedAt: msg.submittedAt });
121
+ }
122
+ if (config.redirectAtEnd) {
123
+ post({
124
+ source: BRIDGE_SOURCE,
125
+ type: 'REQUEST_REDIRECT',
126
+ target: config.redirectAtEnd,
127
+ external: isExternalRedirect(config.redirectAtEnd),
128
+ });
129
+ }
130
+ return;
131
+ }
132
+ if (msg.reason === 'already_submitted_once' || msg.reason === 'already_submitted_in_window') {
133
+ setLifecycle('locked');
134
+ return;
135
+ }
136
+ setLifecycle('error', { message: msg.message });
137
+ }
138
+
139
+ function handleHostMessage(msg: THostToWebviewMessage): void {
140
+ switch (msg.type) {
141
+ case 'SUBMIT_RESULT':
142
+ handleSubmitResult(msg);
143
+ return;
144
+ case 'SESSION_EXPIRED':
145
+ setLifecycle('session-expired');
146
+ return;
147
+ case 'PROGRESS_SAVED':
148
+ if (msg.responseId) responseId = msg.responseId;
149
+ return;
150
+ case 'SET_LOCALE':
151
+ if (model) model.locale = msg.locale;
152
+ return;
153
+ default:
154
+ // INIT / SURVEY_LOADED are consumed by the React app (it builds
155
+ // the model); the controller ignores them.
156
+ return;
157
+ }
158
+ }
159
+
160
+ return {
161
+ attachModel,
162
+ handleHostMessage,
163
+ getResponseId: () => responseId,
164
+ getLifecycle: () => lifecycle,
165
+ };
166
+ }
@@ -0,0 +1,118 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * Pure, DOM-free SurveyJS lifecycle helpers shared by the WebView runtime
7
+ * controller and the host shell. These mirror the frontend runtime
8
+ * (`frontend/src/runtime/SurveyRuntime.tsx`) so mobile enforces the SAME
9
+ * once-per-user / schedule / redirect semantics as the web — the behaviour
10
+ * parity the plan requires.
11
+ *
12
+ * Nothing here touches `document`/`window` or `survey-react-ui`, so the unit
13
+ * tests run headless in Node against the real `survey-core` model.
14
+ */
15
+
16
+ import type { IRuntimeSectionConfig } from '../styles/section';
17
+ import type { ISubmissionEnforcePayload } from '../api/surveys';
18
+
19
+ /** Build the `enforce` payload the backend re-validates the submission against. */
20
+ export function buildEnforcePayload(
21
+ config: IRuntimeSectionConfig,
22
+ responseId: string | null,
23
+ pageNo: number,
24
+ ): ISubmissionEnforcePayload {
25
+ const scheduleWindow = config.oncePerSchedule
26
+ ? resolveScheduleWindow(config.startTime, config.endTime)
27
+ : null;
28
+ return {
29
+ oncePerUser: config.oncePerUser,
30
+ oncePerSchedule: config.oncePerSchedule,
31
+ allowAnonymous: config.allowAnonymous,
32
+ windowStart: scheduleWindow?.start ?? null,
33
+ windowEnd: scheduleWindow?.end ?? null,
34
+ responseId: responseId ?? undefined,
35
+ editMode: false,
36
+ progress: { pageNo, triggerType: 'finished' },
37
+ };
38
+ }
39
+
40
+ /** True when the current local time falls outside a configured daily window. */
41
+ export function isOutsideSchedule(config: { startTime: string | null; endTime: string | null }): boolean {
42
+ if (!config.startTime || !config.endTime) return false;
43
+ if (config.startTime === '00:00' && config.endTime === '00:00') return false;
44
+ const start = parseClockTime(config.startTime);
45
+ const end = parseClockTime(config.endTime);
46
+ if (start === null || end === null) return false;
47
+ const now = new Date();
48
+ const minutesNow = now.getHours() * 60 + now.getMinutes();
49
+ if (start <= end) {
50
+ return minutesNow < start || minutesNow > end;
51
+ }
52
+ return minutesNow > end && minutesNow < start;
53
+ }
54
+
55
+ export function resolveScheduleWindow(
56
+ startTime: string | null,
57
+ endTime: string | null,
58
+ ): { start: string; end: string } | null {
59
+ const start = parseClockTimeParts(startTime);
60
+ const end = parseClockTimeParts(endTime);
61
+ if (!start || !end) return null;
62
+ const now = new Date();
63
+ const windowStart = new Date(now);
64
+ windowStart.setHours(start.hour, start.minute, 0, 0);
65
+ const windowEnd = new Date(now);
66
+ windowEnd.setHours(end.hour, end.minute, 0, 0);
67
+ if (windowStart.getTime() > windowEnd.getTime()) {
68
+ if (windowEnd.getTime() > now.getTime()) {
69
+ windowStart.setDate(windowStart.getDate() - 1);
70
+ } else {
71
+ windowEnd.setDate(windowEnd.getDate() + 1);
72
+ }
73
+ }
74
+ return { start: windowStart.toISOString(), end: windowEnd.toISOString() };
75
+ }
76
+
77
+ function parseClockTime(time: string | null): number | null {
78
+ const parts = parseClockTimeParts(time);
79
+ return parts ? parts.hour * 60 + parts.minute : null;
80
+ }
81
+
82
+ function parseClockTimeParts(time: string | null): { hour: number; minute: number } | null {
83
+ if (!time || !/^\d{1,2}:\d{2}$/.test(time)) return null;
84
+ const parts = time.split(':').map((part) => Number.parseInt(part, 10));
85
+ return { hour: parts[0] ?? 0, minute: parts[1] ?? 0 };
86
+ }
87
+
88
+ /** Generate a `R_...` response id; uses Web Crypto when available. */
89
+ export function cryptoRandomHex(byteLength: number): string {
90
+ const bytes = new Uint8Array(byteLength);
91
+ const webCrypto = (globalThis as { crypto?: { getRandomValues?: (array: Uint8Array) => void } }).crypto;
92
+ if (webCrypto && typeof webCrypto.getRandomValues === 'function') {
93
+ webCrypto.getRandomValues(bytes);
94
+ } else {
95
+ for (let i = 0; i < byteLength; i++) bytes[i] = Math.floor(Math.random() * 256);
96
+ }
97
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase();
98
+ }
99
+
100
+ export function newResponseId(): string {
101
+ return `R_${cryptoRandomHex(8)}`;
102
+ }
103
+
104
+ /**
105
+ * CMS status labels can arrive as editor HTML (`<p>…</p>`); the WebView shell
106
+ * surfaces plain text, so strip tags down to readable content.
107
+ */
108
+ export function stripHtml(label: string | null): string | null {
109
+ const value = label?.trim() ?? '';
110
+ if (value === '') return null;
111
+ const text = value
112
+ .replace(/<!--[\s\S]*?-->/g, '')
113
+ .replace(/<br\s*\/?>(?!$)/gi, ' ')
114
+ .replace(/<[^>]+>/g, '')
115
+ .replace(/&nbsp;/gi, ' ')
116
+ .trim();
117
+ return text === '' ? null : text;
118
+ }
@@ -0,0 +1,347 @@
1
+ /*
2
+ SPDX-FileCopyrightText: 2026 Humdek, University of Bern
3
+ SPDX-License-Identifier: MPL-2.0
4
+ */
5
+ /**
6
+ * `surveyjs` mobile style — native host shell.
7
+ *
8
+ * Hosts the OFFICIAL SurveyJS web runtime (`survey-core` + `survey-react-ui`)
9
+ * inside an isolated, self-contained WebView (react-native-webview on native,
10
+ * an iframe on the Expo web export) and drives it with the typed postMessage
11
+ * bridge. This shell:
12
+ * - owns ALL authenticated backend access via `@selfhelp/shared`
13
+ * `MobileHostServices` (token + 401-refresh + session-expiry live in the
14
+ * host; the WebView never sees the token);
15
+ * - answers the runtime's intents (`LOAD_SURVEY` / `SAVE_PROGRESS` /
16
+ * `SUBMIT_SURVEY`) by calling `/published`, `/progress`, `/submit` and
17
+ * returning the result (`SURVEY_LOADED` / `PROGRESS_SAVED` /
18
+ * `SUBMIT_RESULT` / `SESSION_EXPIRED`);
19
+ * - sizes the WebView from `RESIZE`, handles `REQUEST_REDIRECT`, and renders
20
+ * the loading / error / retry / session-expired chrome around it.
21
+ *
22
+ * The survey UI (questions, validation, completion) is owned entirely by
23
+ * SurveyJS inside the WebView. This shell only renders the outer chrome.
24
+ */
25
+
26
+ import { useCallback, useMemo, useRef, useState } from 'react';
27
+ import { ActivityIndicator, Linking, Platform, Pressable, Text, View } from 'react-native';
28
+
29
+ import { getMobileHostServices } from '@selfhelp/shared/plugin-sdk';
30
+ import type { IMobileHostServices } from '@selfhelp/shared/plugin-sdk';
31
+
32
+ import {
33
+ SurveyHostError,
34
+ fetchDraft,
35
+ loadPublishedSurvey,
36
+ saveProgress,
37
+ submitSurvey,
38
+ } from '../api/surveys';
39
+ import {
40
+ BRIDGE_SOURCE,
41
+ isWebviewToHostMessage,
42
+ type THostToWebviewMessage,
43
+ type TWebviewToHostMessage,
44
+ } from '../bridge/messages';
45
+ import { SURVEYJS_WEBVIEW_HTML } from '../webview/htmlAsset';
46
+ import type { IWebViewTransportProps } from './transport/contract';
47
+ import {
48
+ buildRuntimeConfigFromSection,
49
+ configToServerConfig,
50
+ extractSurveyId,
51
+ type ISectionLike,
52
+ } from './section';
53
+
54
+ export interface ISurveyJsStyleProps {
55
+ section: ISectionLike;
56
+ values?: Record<string, unknown>;
57
+ }
58
+
59
+ type TShellState =
60
+ | { kind: 'running' }
61
+ | { kind: 'session-expired' }
62
+ | { kind: 'error'; message: string };
63
+
64
+ /**
65
+ * URLs the WebView itself may load — everything else is blocked. This is the
66
+ * navigation guard wired into the native transport's
67
+ * `onShouldStartLoadWithRequest`, so it is the enforcement point for "no
68
+ * arbitrary navigation / block unknown external URLs". Only the self-contained
69
+ * runtime document load is allowed (`source={{ html }}` resolves to
70
+ * `about:blank` on native, `about:srcdoc` in the web-export iframe; `data:` is
71
+ * permitted for inline assets the runtime may reference). Real redirects never
72
+ * happen via WebView navigation — the runtime emits `REQUEST_REDIRECT` and the
73
+ * native host performs the navigation. Exported for the WebView security tests.
74
+ */
75
+ export function isAllowedWebViewUrl(url: string): boolean {
76
+ return url === '' || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:srcdoc');
77
+ }
78
+
79
+ function loadTransport(): React.ComponentType<IWebViewTransportProps> {
80
+ // Lazy-require the matching transport so the native module
81
+ // (react-native-webview) is never evaluated on web, and the DOM iframe is
82
+ // never evaluated on native. `require` is provided by the RN runtime.
83
+ if (Platform.OS === 'web') {
84
+ return (require('./transport/SurveyWebViewWeb') as {
85
+ SurveyWebViewWeb: React.ComponentType<IWebViewTransportProps>;
86
+ }).SurveyWebViewWeb;
87
+ }
88
+ return (require('./transport/SurveyWebViewNative') as {
89
+ SurveyWebViewNative: React.ComponentType<IWebViewTransportProps>;
90
+ }).SurveyWebViewNative;
91
+ }
92
+
93
+ function extractDraftData(payload: Record<string, unknown>): Record<string, unknown> {
94
+ const data = payload.data;
95
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
96
+ return data as Record<string, unknown>;
97
+ }
98
+ return {};
99
+ }
100
+
101
+ export function SurveyJsStyle({ section }: ISurveyJsStyleProps): React.ReactElement | null {
102
+ const surveyKey = useMemo(() => extractSurveyId(section), [section]);
103
+ const config = useMemo(() => buildRuntimeConfigFromSection(section), [section]);
104
+ const host = useMemo<IMobileHostServices | null>(() => getMobileHostServices(), []);
105
+
106
+ const [state, setState] = useState<TShellState>({ kind: 'running' });
107
+ const [height, setHeight] = useState<number>(320);
108
+ const [reloadKey, setReloadKey] = useState<number>(0);
109
+
110
+ const postRef = useRef<((json: string) => void) | null>(null);
111
+
112
+ const post = useCallback((message: THostToWebviewMessage): void => {
113
+ postRef.current?.(JSON.stringify(message));
114
+ }, []);
115
+
116
+ const handleRedirect = useCallback((target: string, external: boolean): void => {
117
+ if (Platform.OS === 'web') {
118
+ const location = (globalThis as { location?: { assign?: (t: string) => void } }).location;
119
+ if (location && typeof location.assign === 'function') {
120
+ location.assign(target);
121
+ return;
122
+ }
123
+ }
124
+ if (external) {
125
+ void Linking.openURL(target);
126
+ return;
127
+ }
128
+ // Internal CMS-keyword navigation on native needs the host router, which
129
+ // a decoupled plugin package cannot reach. Documented limitation: the
130
+ // completion screen stays visible instead.
131
+ console.warn(`[surveyjs] internal redirect "${target}" is not supported on native; staying on completion.`);
132
+ }, []);
133
+
134
+ const onIntent = useCallback(
135
+ async (message: TWebviewToHostMessage): Promise<void> => {
136
+ if (!host || !surveyKey) return;
137
+ switch (message.type) {
138
+ case 'READY':
139
+ post({ source: BRIDGE_SOURCE, type: 'INIT', surveyKey, config, theme: null, locale: null });
140
+ return;
141
+ case 'LOAD_SURVEY':
142
+ try {
143
+ const published = await loadPublishedSurvey(
144
+ host,
145
+ surveyKey,
146
+ configToServerConfig(config),
147
+ {},
148
+ );
149
+ let draft: { responseId: string; pageNo: number; data: Record<string, unknown> } | null =
150
+ null;
151
+ if (!config.restartOnRefresh && !published.state.lockoutReason) {
152
+ try {
153
+ const d = await fetchDraft(host, surveyKey, published.state.draft?.responseId);
154
+ if (d) {
155
+ draft = {
156
+ responseId: d.responseId,
157
+ pageNo: d.pageNo,
158
+ data: extractDraftData(d.payload),
159
+ };
160
+ }
161
+ } catch {
162
+ /* draft is best-effort; fall through with none */
163
+ }
164
+ }
165
+ post({
166
+ source: BRIDGE_SOURCE,
167
+ type: 'SURVEY_LOADED',
168
+ definition: published.definition,
169
+ tokens: published.tokens,
170
+ extraParams: published.extraParams,
171
+ runtimeConfig: published.runtimeConfig,
172
+ state: published.state,
173
+ draft,
174
+ });
175
+ } catch (err) {
176
+ handleHostError(err);
177
+ }
178
+ return;
179
+ case 'SAVE_PROGRESS':
180
+ try {
181
+ const saved = await saveProgress(host, surveyKey, {
182
+ responseId: message.responseId,
183
+ pageNo: message.pageNo,
184
+ payload: { data: message.data, triggerType: 'updated', locale: message.locale ?? null },
185
+ });
186
+ post({ source: BRIDGE_SOURCE, type: 'PROGRESS_SAVED', ok: true, responseId: saved.responseId });
187
+ } catch (err) {
188
+ if (err instanceof SurveyHostError && err.sessionExpired) {
189
+ post({ source: BRIDGE_SOURCE, type: 'SESSION_EXPIRED' });
190
+ setState({ kind: 'session-expired' });
191
+ return;
192
+ }
193
+ post({ source: BRIDGE_SOURCE, type: 'PROGRESS_SAVED', ok: false });
194
+ }
195
+ return;
196
+ case 'SUBMIT_SURVEY':
197
+ try {
198
+ const result = await submitSurvey(host, surveyKey, message.data, message.enforce);
199
+ post({
200
+ source: BRIDGE_SOURCE,
201
+ type: 'SUBMIT_RESULT',
202
+ ok: true,
203
+ responseId: result.responseId,
204
+ submittedAt: result.submittedAt,
205
+ });
206
+ } catch (err) {
207
+ if (err instanceof SurveyHostError && err.sessionExpired) {
208
+ post({ source: BRIDGE_SOURCE, type: 'SESSION_EXPIRED' });
209
+ setState({ kind: 'session-expired' });
210
+ return;
211
+ }
212
+ const reason = err instanceof SurveyHostError ? err.reason : undefined;
213
+ post({
214
+ source: BRIDGE_SOURCE,
215
+ type: 'SUBMIT_RESULT',
216
+ ok: false,
217
+ reason,
218
+ message: err instanceof Error ? err.message : 'Submission failed.',
219
+ });
220
+ }
221
+ return;
222
+ case 'RESIZE':
223
+ if (message.height > 0) setHeight(Math.ceil(message.height));
224
+ return;
225
+ case 'REQUEST_REDIRECT':
226
+ handleRedirect(message.target, message.external);
227
+ return;
228
+ case 'RUNTIME_ERROR':
229
+ setState({ kind: 'error', message: message.message });
230
+ return;
231
+ case 'UNSUPPORTED':
232
+ console.warn(`[surveyjs] unsupported feature in WebView runtime: ${message.feature}`);
233
+ return;
234
+ default:
235
+ return;
236
+ }
237
+
238
+ function handleHostError(err: unknown): void {
239
+ if (err instanceof SurveyHostError && err.sessionExpired) {
240
+ setState({ kind: 'session-expired' });
241
+ return;
242
+ }
243
+ setState({
244
+ kind: 'error',
245
+ message: err instanceof Error ? `Survey not available: ${err.message}` : 'Survey not available.',
246
+ });
247
+ }
248
+ },
249
+ [host, surveyKey, config, post, handleRedirect],
250
+ );
251
+
252
+ const onMessage = useCallback(
253
+ (raw: unknown): void => {
254
+ let parsed: unknown = raw;
255
+ if (typeof raw === 'string') {
256
+ try {
257
+ parsed = JSON.parse(raw);
258
+ } catch {
259
+ return;
260
+ }
261
+ }
262
+ if (isWebviewToHostMessage(parsed)) {
263
+ void onIntent(parsed);
264
+ }
265
+ },
266
+ [onIntent],
267
+ );
268
+
269
+ const setPost = useCallback((fn: (json: string) => void): void => {
270
+ postRef.current = fn;
271
+ }, []);
272
+
273
+ const retry = useCallback((): void => {
274
+ setState({ kind: 'running' });
275
+ setHeight(320);
276
+ setReloadKey((key) => key + 1);
277
+ }, []);
278
+
279
+ if (!surveyKey) {
280
+ return (
281
+ <Notice tone="warning" text="The SurveyJS section is missing a selected survey." />
282
+ );
283
+ }
284
+ if (!host) {
285
+ return (
286
+ <Notice
287
+ tone="warning"
288
+ text="SurveyJS needs a newer SelfHelp mobile app. Please update the app to take this survey."
289
+ />
290
+ );
291
+ }
292
+ if (state.kind === 'session-expired') {
293
+ return (
294
+ <Notice tone="neutral" text="Your session has expired. Please sign in again to continue." />
295
+ );
296
+ }
297
+ if (state.kind === 'error') {
298
+ return (
299
+ <View style={{ padding: 12, borderWidth: 1, borderColor: '#fa5252', borderRadius: 6, gap: 8 }}>
300
+ <Text style={{ color: '#c92a2a' }}>{state.message}</Text>
301
+ <Pressable
302
+ onPress={retry}
303
+ accessibilityRole="button"
304
+ style={{ alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 12, backgroundColor: '#e9ecef', borderRadius: 6 }}
305
+ >
306
+ <Text style={{ color: '#212529', fontWeight: '600' }}>Retry</Text>
307
+ </Pressable>
308
+ </View>
309
+ );
310
+ }
311
+
312
+ const Transport = loadTransport();
313
+ return (
314
+ <View style={{ paddingVertical: 8 }}>
315
+ <Transport
316
+ key={reloadKey}
317
+ html={SURVEYJS_WEBVIEW_HTML}
318
+ height={height}
319
+ onMessage={onMessage}
320
+ setPost={setPost}
321
+ isAllowedUrl={isAllowedWebViewUrl}
322
+ />
323
+ </View>
324
+ );
325
+ }
326
+
327
+ function Notice({ tone, text }: { tone: 'warning' | 'neutral'; text: string }): React.ReactElement {
328
+ const palette =
329
+ tone === 'warning'
330
+ ? { border: '#fab005', color: '#856404' }
331
+ : { border: '#dee2e6', color: '#495057' };
332
+ return (
333
+ <View style={{ padding: 12, borderWidth: 1, borderColor: palette.border, borderRadius: 6 }}>
334
+ <Text style={{ color: palette.color }}>{text}</Text>
335
+ </View>
336
+ );
337
+ }
338
+
339
+ /** Re-exported so the host can show a spinner consistently if it wants. */
340
+ export function SurveyJsLoading(): React.ReactElement {
341
+ return (
342
+ <View style={{ paddingVertical: 16, alignItems: 'center' }}>
343
+ <ActivityIndicator />
344
+ <Text style={{ marginTop: 8, color: '#495057' }}>Loading survey…</Text>
345
+ </View>
346
+ );
347
+ }