From 75c0eb3c4e5890978bb038600be1bf16c2df5aae Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Thu, 18 Jun 2026 17:02:11 +0400 Subject: [PATCH] fix(widget): carry the message in the URL as well as window.name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The primary path passed the typed message only via window.name, which is destroyed when an iOS universal-link intercept tears down the originating tab — so the message was lost. Send it over both channels (window.name + URL hash); the web app reads window.name first and falls back to the hash. Also export buildTargetUrl/normalizeMessage and add unit tests for URL construction (none existed). Refs DoctorinaAI/doctorina#3940 --- src/widget/tab-opener.ts | 17 +++++++---- tests/widget-tab-opener.test.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 tests/widget-tab-opener.test.ts diff --git a/src/widget/tab-opener.ts b/src/widget/tab-opener.ts index 329263a..eeab727 100644 --- a/src/widget/tab-opener.ts +++ b/src/widget/tab-opener.ts @@ -6,7 +6,7 @@ const DEFAULT_PARAMS: Readonly> = Object.freeze({ utm_medium: 'user_message', }); -function normalizeMessage(raw: string): string { +export function normalizeMessage(raw: string): string { return raw.trim().replace(/\s+/g, ' '); } @@ -14,7 +14,7 @@ function effectiveParams(config: WidgetConfig): Record { return { ...DEFAULT_PARAMS, ...(config.params ?? {}) }; } -function buildTargetUrl(config: WidgetConfig, message?: string): string { +export function buildTargetUrl(config: WidgetConfig, message?: string): string { const url = new URL(config.targetUrl); url.searchParams.set('auto_accept_policies', '1'); url.searchParams.set('referrer', window.location.href); @@ -33,13 +33,17 @@ function buildTargetUrl(config: WidgetConfig, message?: string): string { /** * Opens the target app with the user's message. * - * Primary: window.open() + window.name (no size limit, cross-origin safe). - * Fallback (popup blocked): redirect via window.location with message in URL hash. + * The message is sent over BOTH channels so losing one still delivers it: + * - window.name — survives cross-origin navigation, no size limit (primary); + * - URL hash — survives when window.name is gone (e.g. an iOS universal-link + * intercept tears down the originating tab) and is also the popup-blocked + * fallback path. + * The web app reads window.name first and only falls back to the hash. */ export function openTarget(config: WidgetConfig, message: string): void { const text = normalizeMessage(message); - // Try opening a new tab (must be synchronous in click handler for Safari) + // Try opening a new tab (must be synchronous in the click handler for Safari) const newTab = window.open('about:blank', '_blank'); const popupBlocked = !newTab || newTab.closed; @@ -53,7 +57,8 @@ export function openTarget(config: WidgetConfig, message: string): void { } payload.params = effectiveParams(config); newTab.name = JSON.stringify(payload); - newTab.location.href = buildTargetUrl(config); + // Also carry the message in the URL so it survives a lost window.name. + newTab.location.href = buildTargetUrl(config, text || undefined); } else { // Fallback: redirect in current tab, message in URL hash window.location.href = buildTargetUrl(config, text || undefined); diff --git a/tests/widget-tab-opener.test.ts b/tests/widget-tab-opener.test.ts new file mode 100644 index 0000000..8b078c2 --- /dev/null +++ b/tests/widget-tab-opener.test.ts @@ -0,0 +1,50 @@ +import { buildTargetUrl, normalizeMessage } from '../src/widget/tab-opener'; +import type { WidgetConfig } from '../src/widget/config'; + +const base: WidgetConfig = { targetUrl: 'https://app.doctorina.com' }; + +describe('normalizeMessage', () => { + it('trims and collapses internal whitespace', () => { + expect(normalizeMessage(' hello world \n there ')).toBe('hello world there'); + }); + + it('returns an empty string for whitespace-only input', () => { + expect(normalizeMessage(' \n\t ')).toBe(''); + }); +}); + +describe('buildTargetUrl', () => { + it('targets the configured app and always sets auto_accept_policies + referrer', () => { + const url = new URL(buildTargetUrl(base)); + expect(url.origin).toBe('https://app.doctorina.com'); + expect(url.searchParams.get('auto_accept_policies')).toBe('1'); + expect(url.searchParams.get('referrer')).toBe(window.location.href); + }); + + it('adds the default utm_medium=user_message', () => { + const url = new URL(buildTargetUrl(base)); + expect(url.searchParams.get('utm_medium')).toBe('user_message'); + }); + + it('merges custom params and lets them override the default utm_medium', () => { + const url = new URL(buildTargetUrl({ ...base, params: { utm_medium: 'campaign', utm_source: 'landing' } })); + expect(url.searchParams.get('utm_medium')).toBe('campaign'); + expect(url.searchParams.get('utm_source')).toBe('landing'); + }); + + it('omits the hash when no message is given', () => { + expect(new URL(buildTargetUrl(base)).hash).toBe(''); + }); + + it('carries the message in the URL hash, round-trip safe (unicode + special chars)', () => { + const message = 'у меня болит голова & горло'; + const url = new URL(buildTargetUrl(base, message)); + expect(url.hash.length).toBeGreaterThan(1); + expect(decodeURIComponent(url.hash.slice(1))).toBe(message); + }); + + it('respects a custom target URL (e.g. the staging web app)', () => { + const url = new URL(buildTargetUrl({ ...base, targetUrl: 'https://doctorina-development.web.app' })); + expect(url.origin).toBe('https://doctorina-development.web.app'); + }); +});