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
5 changes: 5 additions & 0 deletions .changeset/widget-consent-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

`waitForConsent` holds the chat behind a notice until `ChatCops.setConsent(true)`. While it waits the widget uses no storage and calls no API; `setConsent(false)` closes the chat and removes what it saved. The notice has a button that calls `onConsentRequest`, and the script tag takes `data-wait-for-consent`.
6 changes: 6 additions & 0 deletions api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ declare const _default: {
token?: string;
} | null): void;
setContext(data: Record<string, string> | null): void;
setConsent(value: boolean): void;
update(changes: WidgetUpdate): void;
destroy(): void;
on<E extends WidgetEventType>(event: E, handler: WidgetEventHandler<E>): () => void;
Expand Down Expand Up @@ -87,6 +88,7 @@ export declare class Widget {
get isInline(): boolean;
constructor(config: WidgetConfig);
init(): void;
setConsent(consent: boolean): void;
open(): void;
close(): void;
toggle(): void;
Expand Down Expand Up @@ -149,6 +151,8 @@ export interface WidgetConfig {
onError?: (error: Error) => void;
onLeadCaptured?: (leadData?: Record<string, unknown>) => void;
onPreChatSubmit?: (data: Record<string, string>) => void;
waitForConsent?: boolean;
onConsentRequest?: () => void;
}

export type WidgetEventHandler<E extends WidgetEventType = WidgetEventType> = (...args: WidgetEvents[E]) => void;
Expand Down Expand Up @@ -209,6 +213,8 @@ export interface WidgetLocaleStrings {
sourcesLabel: string;
disclosure: string;
disclosureLearnMore: string;
consentNotice: string;
consentButton: string;
}

export type WidgetUpdate = Pick<WidgetConfig, 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings'> & {
Expand Down
21 changes: 21 additions & 0 deletions e2e/fixtures/consent.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChatCops e2e host page, waiting for cookie consent</title>
</head>
<body>
<main>
<h1>Host page</h1>
<p>The widget below waits until the visitor accepts functional cookies.</p>
<button type="button" id="accept">Accept functional cookies</button>
<button type="button" id="reject">Reject</button>
</main>
<script src="/chatcops.min.js" data-api-url="/api/chat" data-wait-for-consent></script>
<script>
document.getElementById('accept').addEventListener('click', () => ChatCops.setConsent(true));
document.getElementById('reject').addEventListener('click', () => ChatCops.setConsent(false));
</script>
</body>
</html>
1 change: 1 addition & 0 deletions e2e/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const files = {
'/full.html': ['fixtures/full.html', 'text/html'],
'/theme.html': ['fixtures/theme.html', 'text/html'],
'/prechat.html': ['fixtures/prechat.html', 'text/html'],
'/consent.html': ['fixtures/consent.html', 'text/html'],
'/locale.html': ['fixtures/locale.html', 'text/html'],
'/csp.html': ['fixtures/csp.html', 'text/html'],
'/locales/zz.js': ['fixtures/locales/zz.js', 'text/javascript'],
Expand Down
30 changes: 30 additions & 0 deletions e2e/tests/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,36 @@ for (const [build, path] of [['loader', '/'], ['single-file build', '/full.html'
});
}

test('with data-wait-for-consent, opening the chat touches no storage and no API until setConsent(true)', async ({ page }) => {
await countStorageCalls(page);
const apiCalls: string[] = [];
page.on('request', (request) => new URL(request.url()).pathname === '/api/chat' && apiCalls.push(request.method()));
await page.goto('/consent.html');
await page.locator(fab).click();
await expect(page.locator('.cc-panel')).toHaveClass(/cc-visible/);
await expect(page.locator('.cc-consent')).toContainText('Chat is available once you accept functional cookies.');
await expect(page.locator('.cc-message')).toHaveCount(0);
await page.waitForTimeout(300);
expect(await storageCalls(page)).toEqual([]);
expect(apiCalls).toEqual([]);

await page.locator('#accept').click();
await expect(page.locator('.cc-consent')).toHaveCount(0);
await expect(page.locator('.cc-message-assistant')).toHaveCount(1);
expect(await storageCalls(page)).toContain('getItem');

await page.locator('.cc-input').fill('Hi there');
await page.locator('.cc-send-btn').click();
await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.');
expect(await page.evaluate(() => localStorage.getItem('chatcops_conversation'))).toContain('Hi there');

await page.locator('#reject').click();
await expect(page.locator('.cc-panel')).not.toHaveClass(/cc-visible/);
expect(await page.evaluate(() => localStorage.getItem('chatcops_conversation'))).toBeNull();
await page.locator(fab).click();
await expect(page.locator('.cc-consent')).toBeVisible();
});

test('a conversation comes back after a reload, once the chat is opened', async ({ page }) => {
await page.goto('/');
await page.locator(fab).click();
Expand Down
18 changes: 17 additions & 1 deletion e2e/tests/states.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import AxeBuilder from '@axe-core/playwright';

// Every widget state in F-05's list that exists today, each checked with axe
// against WCAG 2.2 AA in light, dark and right-to-left. States still to come:
// S8 tool confirmation (F-08), S12 handoff (F-10), S17 waiting for consent.
// S8 tool confirmation (F-08), S12 handoff (F-10).

const fab = '.cc-fab';
const panel = '.cc-panel';
Expand Down Expand Up @@ -185,6 +185,22 @@ for (const look of looks) {
await expectAccessible(page);
});

test('S17 waiting for consent', async ({ page }) => {
await start(page, look, { waitForConsent: true });
// A function can't be passed in from the test, so set the widget up again with one.
await page.evaluate((config) => {
const chat = (window as unknown as { ChatCops: Api }).ChatCops;
chat.destroy();
chat.init({ ...config, onConsentRequest: () => undefined });
}, { apiUrl: '/api/chat', waitForConsent: true, ...look.config });
await openChat(page);
await expect(page.locator('.cc-consent')).toBeVisible();
await expect(page.locator('.cc-consent-btn')).toBeVisible();
await page.locator('.cc-consent-btn').focus();
await expect(page.locator('.cc-consent-btn')).toBeFocused();
await expectAccessible(page);
});

test.describe('S14 on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });

Expand Down
2 changes: 1 addition & 1 deletion packages/widget/mangle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const EARLIER = [
* an object key, never on another object, never in a string.
*/
export const CHECKED = [
'announcement', 'autoResize', 'cleanupEmptyActionContainers', 'clearError', 'createActionButton',
'announcement', 'autoResize', 'consentEl', 'consented', 'createBubble', 'showConsentNotice', 'cleanupEmptyActionContainers', 'clearError', 'createActionButton',
'createActionsContainer', 'createField', 'createMessageElement', 'createRequest', 'disclosureEl', 'endStream',
'ensureActionsContainer', 'errorElements', 'escapeHtml', 'fieldElements', 'findLatestRegeneratableAssistant',
'findMessage', 'findUserMessageForAssistant', 'getErrorMessage', 'handleKeydown', 'handlePreChatSubmit',
Expand Down
11 changes: 9 additions & 2 deletions packages/widget/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,18 @@ export class ChatClient {
* The signed-in visitor's identity token for the next session, or none.
* The session token from before is dropped, so the next message gets one
* for the new identity (the server starts a new conversation for a
* different user).
* different user). `forget: false` leaves the stored token alone, for a widget that
* may not use storage yet.
*/
setUserToken(token: string | undefined): void {
setUserToken(token: string | undefined, forget = true): void {
if (token === this.userToken) return;
this.userToken = token;
if (forget) this.forgetSession();
else this.saved = {};
}

/** Drops the session token, in memory and from the store. */
forgetSession(): void {
this.saved = {};
this.store.remove(this.sessionKey);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/widget/src/dom/bubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface BubbleOptions {
onClick: () => void;
}

const BUBBLE_SHOWN_KEY = 'chatcops_bubble_shown';
export const BUBBLE_SHOWN_KEY = 'chatcops_bubble_shown';

export class WelcomeBubble {
private el: HTMLDivElement;
Expand Down
10 changes: 4 additions & 6 deletions packages/widget/src/dom/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,10 @@ export class Panel {
return this.messages.addMessage(msg);
}

hideMessages(): void {
this.messages.setVisible(false);
}

showMessages(): void {
this.messages.setVisible(true);
/** The conversation and the input together; the pre-chat form and the consent notice replace them. */
setBodyVisible(visible: boolean): void {
this.messages.setVisible(visible);
this.input.setVisible(visible);
}

destroy(): void {
Expand Down
4 changes: 4 additions & 0 deletions packages/widget/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface WidgetLocaleStrings {
sourcesLabel: string;
disclosure: string;
disclosureLearnMore: string;
consentNotice: string;
consentButton: string;
}

const en: WidgetLocaleStrings = {
Expand Down Expand Up @@ -71,6 +73,8 @@ const en: WidgetLocaleStrings = {
sourcesLabel: 'Sources',
disclosure: "You're chatting with an AI assistant. It can make mistakes.",
disclosureLearnMore: 'Learn more',
consentNotice: 'Chat is available once you accept functional cookies.',
consentButton: 'Cookie settings',
};

const locales: Record<string, WidgetLocaleStrings> = { en };
Expand Down
13 changes: 13 additions & 0 deletions packages/widget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type { WidgetLocaleStrings } from './i18n.js';
export { parseAutoOpen } from './script-config.js';

let instance: Widget | null = null;
// Kept without an instance, so `setConsent()` can come before `init()`; like the loader's, it outlives `destroy()`.
let consent: boolean | undefined;
const persistentHandlers = new Map<WidgetEventType, Set<WidgetEventHandler<never>>>();

const ChatCops = {
Expand All @@ -40,6 +42,7 @@ const ChatCops = {
instance.on(event, handler as WidgetEventHandler);
}
}
if (consent !== undefined) instance.setConsent(consent);
instance.init();
return instance;
},
Expand Down Expand Up @@ -93,6 +96,16 @@ const ChatCops = {
instance?.setContext(data);
},

/**
* Whether the visitor accepted functional cookies, for a chat with `waitForConsent`. `true` shows the chat;
* `false` closes it and removes what it saved in this browser. May come before `init()`; the last call wins.
* While consent is missing `sendMessage()` resolves without sending, so don't wait on a reply.
*/
setConsent(value: boolean): void {
consent = value;
instance?.setConsent(value);
},

/** Changes the theme, branding or wording of the running chat. What you leave out stays as it is. */
update(changes: WidgetUpdate): void {
instance?.update(changes);
Expand Down
14 changes: 14 additions & 0 deletions packages/widget/src/loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface ChatCopsLoader {
setContext(data: Record<string, string> | null): void;
/** Changes the theme, branding or wording of the chat. What you leave out stays as it is. Kept until the chat loads. */
update(changes: WidgetUpdate): void;
/**
* Tells the chat whether the visitor accepted functional cookies. Until `true`, a chat with
* `waitForConsent` shows a notice and never touches storage or the API. `false` closes the
* chat and removes what it saved. Works before `init()` and before the chat loads.
*/
setConsent(consent: boolean): void;
/** The widget's version. */
readonly version: string;
}
Expand Down Expand Up @@ -103,6 +109,8 @@ export function createLoader(
// so a signed-in page doesn't load the chat early.
let user: Parameters<ChatCopsLoader['setUser']>[0] | undefined;
let context: Record<string, string> | null | undefined;
// setConsent() before the chat loads, or before init(): kept across init() like the user.
let consent: boolean | undefined;
// update() calls before the chat loads, for this init() only.
let updates: WidgetUpdate[] = [];
const directionOf = (current: WidgetConfig) => current.dir ?? (current.locale ? registered.get(current.locale)?.dir : undefined);
Expand Down Expand Up @@ -154,6 +162,7 @@ export function createLoader(
// Swapped in one task, so the page never paints without a button.
launcher?.remove();
launcher = button = undefined;
if (consent !== undefined) next.setConsent(consent);
next.init();
if (user !== undefined) next.setUser(user);
if (context !== undefined) next.setContext(context);
Expand Down Expand Up @@ -268,6 +277,11 @@ export function createLoader(
widget?.setContext(next);
},

setConsent(next) {
consent = next;
widget?.setConsent(next);
},

update(changes) {
if (widget) {
widget.update(changes);
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/script-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,6 @@ export function configFromScript(script: HTMLScriptElement): WidgetConfig | unde
dir: (['ltr', 'rtl', 'auto'] as const).find((dir) => dir === script.dataset.dir),
persistence: (['local', 'session', 'none'] as const).find((mode) => mode === script.dataset.persistence),
historyTtlDays: /^\d+$/.test(script.dataset.historyTtlDays ?? '') ? Number(script.dataset.historyTtlDays) : undefined,
waitForConsent: script.dataset.waitForConsent === undefined ? undefined : script.dataset.waitForConsent !== 'false',
};
}
14 changes: 14 additions & 0 deletions packages/widget/src/styles/widget.css
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,17 @@
text-decoration: underline;
}

.cc-consent {
margin: auto;
padding: 24px;
text-align: center;
color: var(--cc-text-secondary);
}

.cc-consent-btn {
margin-top: 12px;
}

.cc-message-error-note {
margin-top: 6px;
font-size: 12px;
Expand Down Expand Up @@ -279,6 +290,7 @@
align-items: center;
}

.cc-consent-btn,
.cc-message-retry,
.cc-message-regenerate {
display: inline-flex;
Expand All @@ -295,6 +307,7 @@
transition: background 0.15s, color 0.15s, opacity 0.15s;
}

.cc-consent-btn:hover,
.cc-message-retry:hover,
.cc-message-regenerate:hover {
background: var(--cc-bg-input);
Expand Down Expand Up @@ -578,6 +591,7 @@

/* Keyboard focus indicators (the launcher's are in launcher.css) */
.cc-header-close:focus-visible,
.cc-consent-btn:focus-visible,
.cc-send-btn:focus-visible,
.cc-suggestion:focus-visible,
.cc-message-retry:focus-visible,
Expand Down
Loading
Loading