Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/widget/tab-opener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ const DEFAULT_PARAMS: Readonly<Record<string, string>> = Object.freeze({
utm_medium: 'user_message',
});

function normalizeMessage(raw: string): string {
export function normalizeMessage(raw: string): string {
return raw.trim().replace(/\s+/g, ' ');
}

function effectiveParams(config: WidgetConfig): Record<string, string> {
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);
Expand All @@ -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;

Expand All @@ -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);
Expand Down
50 changes: 50 additions & 0 deletions tests/widget-tab-opener.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading