diff --git a/__tests__/chatReplyService.test.ts b/__tests__/chatReplyService.test.ts index 891b17b4..d2a7a672 100644 --- a/__tests__/chatReplyService.test.ts +++ b/__tests__/chatReplyService.test.ts @@ -47,6 +47,35 @@ describe('generateChatReply', () => { expect(mockGenerateReplyFn).toHaveBeenCalledWith({ prompt: 'hi', referenceId: 'abc' }) }) + it('omits undefined optional fields from the callable payload', async () => { + mockGenerateReplyFn.mockResolvedValue({ + data: { + reply: 'Hello', + verifiedAt: '2026-01-01T00:00:00.000Z', + }, + }) + + const resultPromise = generateChatReply({ + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + systemInstruction: 'Be concise.', + }) + + if (!resolveAppCheck) { + throw new Error('Expected appCheckReady resolver to be set') + } + resolveAppCheck() + + await resultPromise + + expect(mockGenerateReplyFn).toHaveBeenCalledWith({ + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + systemInstruction: 'Be concise.', + }) + expect(mockGenerateReplyFn.mock.calls[0][0]).not.toHaveProperty('unsyncedHistory') + expect(mockGenerateReplyFn.mock.calls[0][0]).not.toHaveProperty('characterId') + expect(mockGenerateReplyFn.mock.calls[0][0]).not.toHaveProperty('referenceId') + }) + it('forwards structured contents and systemInstruction to the callable payload', async () => { mockGenerateReplyFn.mockResolvedValue({ data: { diff --git a/functions/src/generateReply.test.ts b/functions/src/generateReply.test.ts index 7dd42ff5..6c1f3b3f 100644 --- a/functions/src/generateReply.test.ts +++ b/functions/src/generateReply.test.ts @@ -787,6 +787,34 @@ test("generateReplyHandler rejects unsyncedHistory entries with non-user roles", }); }); +test("generateReplyHandler treats null unsyncedHistory as absent (Firebase web encodes undefined as null)", async () => { + const auth = buildAuth(); + + await withServiceMocks(async () => { + const user = buildUser(auth); + + userRepository.getOrCreateUserByFirebaseIdentity = async () => user; + subscriptionService.getSubscription = async () => buildSubscription(user.id, "payg", 5); + creditService.spendCredits = async () => 'mock-tx-id'; + creditService.getCredits = async () => 4; + + const result = await generateReplyHandler( + { + auth, + data: { + ...buildStructuredRequestData('hello'), + unsyncedHistory: null, + }, + } as never, + { + generateText: async () => ({ text: "reply" }), + } + ); + + assert.equal(result.reply, "reply"); + }); +}); + test("generateReplyHandler validates character ownership before bulk inserting unsyncedHistory", async () => { const auth = buildAuth(); diff --git a/functions/src/generateReply.ts b/functions/src/generateReply.ts index efcb9bc1..d193f933 100644 --- a/functions/src/generateReply.ts +++ b/functions/src/generateReply.ts @@ -314,6 +314,23 @@ export function buildToolsForRequest(tools?: ToolDeclaration[]): Tool[] { return buildAuthorizedToolsArray([googleSearchManifest], []).map(toGenAITool); } +const NON_RETRYABLE_EMPTY_RESPONSE_FINISH_REASONS = new Set([ + "MAX_TOKENS", + "SAFETY", + "RECITATION", + "BLOCKLIST", + "PROHIBITED_CONTENT", + "SPII", + "MALFORMED_FUNCTION_CALL", +]); + +function isRetryableEmptyResponseFinishReason(finishReason: string | undefined): boolean { + if (!finishReason || finishReason === "FINISH_REASON_UNSPECIFIED" || finishReason === "OTHER") { + return true; + } + return !NON_RETRYABLE_EMPTY_RESPONSE_FINISH_REASONS.has(finishReason); +} + function getTextGenerator(): GenerateTextFn { if (textGenerator) { return textGenerator; @@ -327,38 +344,65 @@ function getTextGenerator(): GenerateTextFn { const ai = getGenAIClient(); const tools = buildToolsForRequest(input.tools); - const result = await ai.models.generateContent({ - model: DEFAULT_MODEL, - contents: input.contents as Content[], - config: { - systemInstruction: input.systemInstruction, - maxOutputTokens: MAX_OUTPUT_TOKENS, - thinkingConfig: { thinkingBudget: 0 }, - tools, - }, - }); + for (let attempt = 0; attempt < 2; attempt++) { + const result = await ai.models.generateContent({ + model: DEFAULT_MODEL, + contents: input.contents as Content[], + config: { + systemInstruction: input.systemInstruction, + maxOutputTokens: MAX_OUTPUT_TOKENS, + thinkingConfig: { thinkingBudget: 0 }, + tools, + }, + }); + + if (result.functionCalls && result.functionCalls.length > 0) { + return { + functionCalls: result.functionCalls.map((fc) => ({ + name: fc.name ?? "", + args: fc.args as Record | undefined, + })), + }; + } - if (result.functionCalls && result.functionCalls.length > 0) { - return { - functionCalls: result.functionCalls.map((fc) => ({ - name: fc.name ?? "", - args: fc.args as Record | undefined, - })), - }; - } + const candidates = result.candidates ?? []; - const candidates = result.candidates ?? []; + for (const candidate of candidates) { + const parts = candidate.content?.parts ?? []; + const text = parts + .map((part) => (typeof part.text === "string" ? part.text : "")) + .join("") + .trim(); + + if (text.length > 0) { + return { text, groundingMetadata: candidate.groundingMetadata }; + } + } - for (const candidate of candidates) { - const parts = candidate.content?.parts ?? []; - const text = parts - .map((part) => (typeof part.text === "string" ? part.text : "")) - .join("") - .trim(); + const finishReasons = candidates.map((c) => c.finishReason ?? null); + const shouldRetry = + attempt === 0 && + (candidates.length === 0 || + candidates.some((candidate) => isRetryableEmptyResponseFinishReason(candidate.finishReason))); - if (text.length > 0) { - return { text, groundingMetadata: candidate.groundingMetadata }; + if (shouldRetry) { + logger.warn("generateReply empty model response, retrying once", { + finishReasons, + candidateCount: candidates.length, + }); + continue; } + + logger.error( + attempt === 0 + ? "generateReply model returned empty response with non-retryable finish reason" + : "generateReply model returned empty response after retry", + { + finishReasons, + candidateCount: candidates.length, + }, + ); + break; } throw new HttpsError("internal", "Model returned an empty response."); @@ -455,7 +499,7 @@ function parseInput(data: unknown): { const rawHistory = payload?.unsyncedHistory; let unsyncedHistory: SyncMessage[] | undefined; - if (rawHistory !== undefined) { + if (rawHistory != null) { if (!Array.isArray(rawHistory)) { throw new HttpsError("invalid-argument", "unsyncedHistory must be an array when provided."); } diff --git a/package-lock.json b/package-lock.json index 2cfe3507..72eac089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13696,7 +13696,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 2ed4f9a9..54cd9b59 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react' import { router } from 'expo-router' import { useNavigation } from 'expo-router/react-navigation' import { View, Text as RNText, StyleSheet, Platform, TouchableOpacity, Linking } from 'react-native' +import type { FlatListProps, TextStyle } from 'react-native' import { GiftedChat, Bubble, InputToolbar, Send, MessageText } from 'react-native-gifted-chat' import type { IMessage, User, ComposerProps, SendProps, InputToolbarProps, MessageTextProps } from 'react-native-gifted-chat' import { useSelector } from '@xstate/react' @@ -20,6 +21,15 @@ import type { Character } from '~/services/characterService' const defaultAvatarUrl = 'https://via.placeholder.com/150' +const webMessageTextWrapStyle = { + wordBreak: 'break-word', + overflowWrap: 'anywhere', +} as TextStyle + +/** Native WebViews in inverted lists can paint over sibling rows unless clipping is disabled. */ +const groundingListViewProps: Pick, 'removeClippedSubviews'> | undefined = + Platform.OS === 'web' ? undefined : { removeClippedSubviews: false } + interface ChatViewProps { characterId: string } @@ -132,24 +142,60 @@ function ChatViewContent({ ) const renderBubble = useCallback( - (props: any) => ( - ) => ( - - - - )} - /> - ), + (props: any) => { + const hasGrounding = Boolean( + (props.currentMessage as GroundedIMessage | undefined)?.groundingMetadata, + ) + const webBubbleConstraints = + Platform.OS === 'web' + ? ({ maxWidth: '80%', minWidth: 0, overflow: 'hidden' } as const) + : {} + + return ( + ) => ( + + + + )} + /> + ) + }, [colors, roundness], ) @@ -332,6 +378,7 @@ function ChatViewContent({ renderCustomView={renderCustomView} isCustomViewBottom messageIdGenerator={() => `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`} + listViewProps={groundingListViewProps} renderAvatarOnTop messagesContainerStyle={styles.messagesContainer} minInputToolbarHeight={56} @@ -445,8 +492,12 @@ const styles = StyleSheet.create({ }, groundingContainer: { paddingHorizontal: 8, - paddingBottom: 8, + paddingBottom: Platform.OS === 'web' ? 0 : 8, gap: 6, + overflow: 'hidden', + width: '100%', + maxWidth: '100%', + minWidth: 0, }, citationRow: { flexDirection: 'row', @@ -464,8 +515,11 @@ const styles = StyleSheet.create({ fontSize: 12, }, searchSuggestions: { - height: 85, backgroundColor: 'transparent', + width: '100%', + maxWidth: '100%', + minWidth: 0, + alignSelf: 'stretch', }, headerTitle: { flexDirection: 'row', diff --git a/src/components/GroundingHtml.tsx b/src/components/GroundingHtml.tsx index 5894ed5c..22226b3a 100644 --- a/src/components/GroundingHtml.tsx +++ b/src/components/GroundingHtml.tsx @@ -1,7 +1,9 @@ -import { Linking } from 'react-native' +import { useMemo, useState } from 'react' +import { Linking, StyleSheet, View } from 'react-native' import type { StyleProp, ViewStyle } from 'react-native' import { WebView } from 'react-native-webview' import { isSafeHttpUrl } from '~/utils/isSafeHttpUrl' +import { sanitizeGroundingHtmlForNative } from '~/utils/sanitizeGroundingHtml' interface GroundingHtmlProps { /** Gemini's searchEntryPoint.renderedContent — the Google Search Suggestions HTML. */ @@ -9,35 +11,127 @@ interface GroundingHtmlProps { style?: StyleProp } +const DEFAULT_MIN_HEIGHT = 48 +const MAX_HEIGHT = 160 + +/** + * Measure the rendered widget height so the WebView can size to its content. + * Snippet scripts/event handlers are stripped before load; only this injected + * script runs in the WebView. + */ +const MEASURE_HEIGHT_SCRIPT = ` +(function() { + function measure() { + var height = Math.max( + document.documentElement ? document.documentElement.scrollHeight : 0, + document.body ? document.body.scrollHeight : 0 + ); + if (height > 0 && window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(String(height)); + } + } + measure(); + window.addEventListener('load', measure); + setTimeout(measure, 100); +})(); +true; +` + +/** Wrap the sanitized API snippet in a minimal document shell. */ +function wrapGroundingHtmlSnippet(html: string): string { + return ` + + + + + + +${html} +` +} + +interface GroundingHtmlWebViewProps { + html: string + minHeight: number + containerStyle: ViewStyle +} + +function GroundingHtmlWebView({ html, minHeight, containerStyle }: GroundingHtmlWebViewProps) { + const [height, setHeight] = useState(minHeight) + const wrappedHtml = useMemo( + () => wrapGroundingHtmlSnippet(sanitizeGroundingHtmlForNative(html)), + [html], + ) + + return ( + + { + const measured = Number.parseInt(event.nativeEvent.data, 10) + if (Number.isFinite(measured) && measured > 0) { + setHeight(Math.min(Math.max(measured, minHeight), MAX_HEIGHT)) + } + }} + onShouldStartLoadWithRequest={(request) => { + if (request.url === 'about:blank') { + return true + } + if (isSafeHttpUrl(request.url)) { + void Linking.openURL(request.url).catch((error) => { + console.warn('Failed to open search suggestion URL', error) + }) + } + return false + }} + /> + + ) +} + /** - * Native renderer for the grounding "Search Suggestions" HTML. JavaScript stays - * disabled (the content is static HTML/CSS); link taps are intercepted and opened - * in the system browser rather than navigating inside the WebView. + * Native renderer for the grounding "Search Suggestions" HTML. Link taps are + * intercepted and opened in the system browser rather than navigating inside + * the WebView. + * + * WebViews inside inverted FlatLists are prone to painting over sibling rows on + * native. The clipped wrapper, fixed flex, opacity workaround, and + * removeClippedSubviews={false} on GiftedChat (see ChatView) keep the widget + * contained within its message bubble. * * The web build resolves to GroundingHtml.web.tsx (react-native-webview has no - * web support) which renders the same HTML in a sandboxed
Suggestion
', + ) + + expect(headMarkup).toContain('.row') + expect(headMarkup).not.toContain('') + }) +}) + +describe('formatGroundingShadowHtml', () => { + it('concatenates head styles and body markup', () => { + const formatted = formatGroundingShadowHtml( + '
Suggestion
', + ) + + expect(formatted).toContain('.row{display:flex}') + expect(formatted).toContain('Suggestion') + }) +}) + +describe('mountGroundingShadowContent', () => { + it('attaches sanitized html and host layout styles to a shadow root', () => { + const host = document.createElement('div') + mountGroundingShadowContent( + host, + '
Hi
', + ) + + expect(host.shadowRoot).not.toBeNull() + expect(host.shadowRoot?.innerHTML).toContain('class="widget"') + expect(host.shadowRoot?.innerHTML).not.toContain(' { + it('returns zero when layout metrics are unavailable', () => { + const host = document.createElement('div') + const shadow = host.attachShadow({ mode: 'open' }) + shadow.innerHTML = '
widget
' + + expect(measureShadowContentHeight(shadow)).toBe(0) + }) +}) + +describe('measureShadowLayout', () => { + it('measures width while the host is temporarily expanded', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const shadow = mountGroundingShadowContent( + host, + '
Suggestion
', + ) + const widget = shadow.querySelector('#widget') + if (widget instanceof HTMLElement) { + Object.defineProperty(widget, 'scrollWidth', { configurable: true, value: 320 }) + Object.defineProperty(widget, 'offsetWidth', { configurable: true, value: 320 }) + widget.getBoundingClientRect = () => + ({ width: 320, height: 40, right: 320, left: 0, top: 0, bottom: 40 } as DOMRect) + } + Object.defineProperty(host, 'scrollWidth', { configurable: true, value: 320 }) + Object.defineProperty(host, 'offsetWidth', { configurable: true, value: 320 }) + + const metrics = measureShadowLayout(host, shadow) + expect(host.style.width).toBe('') + expect(metrics.contentWidth).toBeGreaterThanOrEqual(320) + host.remove() + }) +}) + +describe('getHorizontalWheelDelta', () => { + it('prefers deltaX when horizontal motion dominates', () => { + expect(getHorizontalWheelDelta({ deltaX: 12, deltaY: 2, shiftKey: false })).toBe(12) + }) + + it('maps shift+vertical wheel to horizontal scroll', () => { + expect(getHorizontalWheelDelta({ deltaX: 0, deltaY: 8, shiftKey: true })).toBe(8) + }) +}) + +describe('applyHorizontalWheelToScrollport', () => { + it('scrolls the scrollport when content overflows horizontally', () => { + const scrollEl = document.createElement('div') + Object.defineProperty(scrollEl, 'scrollWidth', { configurable: true, value: 400 }) + Object.defineProperty(scrollEl, 'clientWidth', { configurable: true, value: 200 }) + scrollEl.scrollLeft = 0 + + const scrolled = applyHorizontalWheelToScrollport( + { deltaX: 10, deltaY: 0, shiftKey: false }, + scrollEl, + ) + + expect(scrolled).toBe(true) + expect(scrollEl.scrollLeft).toBe(10) + }) + + it('returns false when the scrollport is already at the horizontal edge', () => { + const scrollEl = document.createElement('div') + Object.defineProperty(scrollEl, 'scrollWidth', { configurable: true, value: 400 }) + Object.defineProperty(scrollEl, 'clientWidth', { configurable: true, value: 200 }) + scrollEl.scrollLeft = 200 + + const scrolled = applyHorizontalWheelToScrollport( + { deltaX: 10, deltaY: 0, shiftKey: false }, + scrollEl, + ) + + expect(scrolled).toBe(false) + expect(scrollEl.scrollLeft).toBe(200) + }) +}) diff --git a/src/utils/__tests__/sanitizeGroundingHtml.test.ts b/src/utils/__tests__/sanitizeGroundingHtml.test.ts new file mode 100644 index 00000000..ec5a7e70 --- /dev/null +++ b/src/utils/__tests__/sanitizeGroundingHtml.test.ts @@ -0,0 +1,52 @@ +import { + sanitizeGroundingHtmlForNative, + sanitizeGroundingHtmlLinksRegex, + stripExecutableGroundingMarkup, +} from '../sanitizeGroundingHtml' + +describe('stripExecutableGroundingMarkup', () => { + it('removes script tags and their contents', () => { + const html = '
ok
more' + expect(stripExecutableGroundingMarkup(html)).toBe('
ok
more') + }) + + it('removes inline event handlers', () => { + const html = '' + expect(stripExecutableGroundingMarkup(html)).toBe('') + }) + + it('removes script tags with whitespace before the closing angle bracket', () => { + const html = '
ok
more' + expect(stripExecutableGroundingMarkup(html)).toBe('
ok
more') + }) + + it('removes script tags with junk between the tag name and closing angle bracket', () => { + const html = '
ok
ipt>' + expect(stripExecutableGroundingMarkup(html)).toBe('') + }) +}) + +describe('sanitizeGroundingHtmlLinksRegex', () => { + it('removes unsafe hrefs', () => { + const html = 'bad' + expect(sanitizeGroundingHtmlLinksRegex(html)).toBe('bad') + }) + + it('keeps safe http(s) hrefs', () => { + const html = 'good' + expect(sanitizeGroundingHtmlLinksRegex(html)).toBe('good') + }) +}) + +describe('sanitizeGroundingHtmlForNative', () => { + it('strips executable markup and unsafe links together', () => { + const html = + 'x' + expect(sanitizeGroundingHtmlForNative(html)).toBe('x') + }) +}) diff --git a/src/utils/groundingShadowContent.ts b/src/utils/groundingShadowContent.ts new file mode 100644 index 00000000..7e9bcbb4 --- /dev/null +++ b/src/utils/groundingShadowContent.ts @@ -0,0 +1,276 @@ +import { isSafeHttpUrl } from '~/utils/isSafeHttpUrl' + +export const GROUNDING_WIDGET_MIN_HEIGHT = 48 +export const GROUNDING_WIDGET_MAX_HEIGHT = 160 +/** Small buffer so subpixel rounding does not clip the widget row. */ +export const GROUNDING_HEIGHT_BUFFER = 2 +/** Wide probe width so percentage-based widget CSS can expand before we measure. */ +export const GROUNDING_WIDTH_MEASURE_PX = 4096 + +/** Shadow-host layout — width comes from the outer sizer; height follows content. */ +export const GROUNDING_SHADOW_HOST_CSS = ` +:host { + display: block; + width: 100%; + overflow: hidden; + line-height: 0; +} +:host > :not(style) { + line-height: normal; +} +` + +/** Tags expected in Google's Search Suggestions widget HTML. */ +const GROUNDING_ALLOWED_BODY_TAGS = new Set([ + 'a', + 'b', + 'br', + 'div', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'img', + 'li', + 'ol', + 'p', + 'path', + 'polygon', + 'polyline', + 'rect', + 'span', + 'strong', + 'svg', + 'ul', +]) + +const GROUNDING_GLOBAL_ALLOWED_ATTRS = new Set([ + 'class', + 'dir', + 'id', + 'lang', + 'role', + 'style', + 'title', +]) + +const GROUNDING_TAG_ALLOWED_ATTRS: Record> = { + a: new Set(['href', 'rel', 'target']), + img: new Set(['alt', 'decoding', 'height', 'loading', 'src', 'width']), + path: new Set(['d', 'fill', 'stroke', 'stroke-width']), + svg: new Set(['fill', 'height', 'viewbox', 'width', 'xmlns']), +} + +/** Disallowed tags whose contents must not be preserved when removed. */ +const GROUNDING_REMOVE_ENTIRELY_TAGS = new Set(['script', 'style']) + +const GROUNDING_URL_ATTRS = new Set(['href', 'src']) + +function isAllowedGroundingAttribute(tagName: string, attributeName: string): boolean { + const lowerName = attributeName.toLowerCase() + if (lowerName.startsWith('on') || lowerName.startsWith('data-') || lowerName.startsWith('aria-')) { + return lowerName.startsWith('aria-') || lowerName.startsWith('data-') + } + if (GROUNDING_GLOBAL_ALLOWED_ATTRS.has(lowerName)) { + return true + } + return GROUNDING_TAG_ALLOWED_ATTRS[tagName]?.has(lowerName) ?? false +} + +function sanitizeGroundingUrlAttribute(element: Element, attributeName: string): void { + const value = element.getAttribute(attributeName) + if (!value || !isSafeHttpUrl(value)) { + element.removeAttribute(attributeName) + return + } + if (element.tagName.toLowerCase() === 'a') { + element.setAttribute('target', '_blank') + element.setAttribute('rel', 'noopener noreferrer') + } +} + +function unwrapDisallowedElement(element: Element): void { + const parent = element.parentNode + if (!parent) { + element.remove() + return + } + while (element.firstChild) { + parent.insertBefore(element.firstChild, element) + } + parent.removeChild(element) +} + +function sanitizeGroundingElementTree(root: ParentNode): void { + for (const element of Array.from(root.querySelectorAll('*'))) { + const tagName = element.tagName.toLowerCase() + if (!GROUNDING_ALLOWED_BODY_TAGS.has(tagName)) { + if (GROUNDING_REMOVE_ENTIRELY_TAGS.has(tagName)) { + element.remove() + } else { + unwrapDisallowedElement(element) + } + continue + } + + for (const attribute of Array.from(element.attributes)) { + const attributeName = attribute.name.toLowerCase() + if (!isAllowedGroundingAttribute(tagName, attributeName)) { + element.removeAttribute(attribute.name) + continue + } + if (GROUNDING_URL_ATTRS.has(attributeName)) { + sanitizeGroundingUrlAttribute(element, attribute.name) + } + } + } +} + +/** Allowlist sanitizer for Google widget HTML before shadow-root injection. */ +export function sanitizeGroundingHtmlLinks(html: string): { headMarkup: string; bodyHtml: string } { + const doc = new DOMParser().parseFromString(html, 'text/html') + sanitizeGroundingElementTree(doc.body) + const headMarkup = Array.from(doc.head.querySelectorAll('style')) + .map((el) => el.outerHTML) + .join('') + return { headMarkup, bodyHtml: doc.body.innerHTML } +} + +/** Sanitized snippet for shadow-root injection (Google's recommended embed path). */ +export function formatGroundingShadowHtml(html: string): string { + const { headMarkup, bodyHtml } = sanitizeGroundingHtmlLinks(html) + return headMarkup + bodyHtml +} + +/** Mount Google's Search Suggestions widget into an open shadow root on `host`. */ +export function mountGroundingShadowContent(host: HTMLElement, html: string): ShadowRoot { + const shadow = host.shadowRoot ?? host.attachShadow({ mode: 'open' }) + shadow.innerHTML = formatGroundingShadowHtml(html) + + if (!shadow.querySelector('style[data-grounding-host-layout="true"]')) { + const layoutStyle = document.createElement('style') + layoutStyle.setAttribute('data-grounding-host-layout', 'true') + layoutStyle.textContent = GROUNDING_SHADOW_HOST_CSS + shadow.appendChild(layoutStyle) + } + + return shadow +} + +/** Fit the host height to the visible widget row (ignores the host's own fixed height). */ +export function measureShadowContentHeight(shadow: ShadowRoot): number { + const host = shadow.host + if (!(host instanceof HTMLElement)) { + return 0 + } + + const hostTop = host.getBoundingClientRect().top + let maxBottom = 0 + + for (const child of Array.from(shadow.children)) { + if (child instanceof HTMLElement && child.tagName !== 'STYLE') { + const style = shadow.ownerDocument?.defaultView?.getComputedStyle(child) + const marginBottom = Number.parseFloat(style?.marginBottom ?? '0') || 0 + const rect = child.getBoundingClientRect() + if (rect.height > 0 || rect.width > 0) { + maxBottom = Math.max(maxBottom, rect.bottom - hostTop + marginBottom) + } + } + } + + if (maxBottom <= 0) { + return 0 + } + + const measured = Math.ceil(maxBottom) + GROUNDING_HEIGHT_BUFFER + return Math.min(measured, GROUNDING_WIDGET_MAX_HEIGHT) +} + +/** Natural content width at probe width so the outer scrollport can pan when chips overflow. */ +export function measureShadowContentWidth(shadow: ShadowRoot, host: HTMLElement): number { + const hostLeft = host.getBoundingClientRect().left + let max = 0 + + for (const child of Array.from(shadow.children)) { + if (child instanceof HTMLElement && child.tagName !== 'STYLE') { + const rect = child.getBoundingClientRect() + max = Math.max( + max, + child.scrollWidth, + child.offsetWidth, + rect.width, + rect.right - hostLeft, + ) + } + } + + for (const element of Array.from(shadow.querySelectorAll('*'))) { + if (element instanceof HTMLElement) { + const rect = element.getBoundingClientRect() + max = Math.max(max, rect.right - hostLeft, element.scrollWidth, element.offsetWidth) + } + } + + return Math.ceil(max) +} + +export interface GroundingLayoutMetrics { + height: number + contentWidth: number +} + +/** Measure width at probe width; height at the host's natural width. */ +export function measureShadowLayout(host: HTMLElement, shadow: ShadowRoot): GroundingLayoutMetrics { + const previousWidth = host.style.width + host.style.width = `${GROUNDING_WIDTH_MEASURE_PX}px` + void host.offsetWidth + + const contentWidth = measureShadowContentWidth(shadow, host) + + host.style.width = previousWidth + void host.offsetWidth + + return { + height: measureShadowContentHeight(shadow), + contentWidth, + } +} + +/** Map trackpad / mouse wheel motion to horizontal scroll delta. */ +export function getHorizontalWheelDelta(event: Pick): number { + if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) { + return event.deltaX + } + if (event.shiftKey && event.deltaY !== 0) { + return event.deltaY + } + return 0 +} + +/** Stop GiftedChat bubble press handlers from swallowing scroll gestures on web. */ +export function stopBubbleScrollCapture(event: { stopPropagation: () => void }): void { + event.stopPropagation() +} + +/** Forward horizontal wheel deltas to the scrollport (works when pointer is over shadow content). */ +export function applyHorizontalWheelToScrollport( + event: Pick, + scrollEl: HTMLElement, +): boolean { + const delta = getHorizontalWheelDelta(event) + if (delta === 0 || scrollEl.scrollWidth <= scrollEl.clientWidth) { + return false + } + const maxScrollLeft = scrollEl.scrollWidth - scrollEl.clientWidth + const nextScrollLeft = Math.min(Math.max(scrollEl.scrollLeft + delta, 0), maxScrollLeft) + if (nextScrollLeft === scrollEl.scrollLeft) { + return false + } + scrollEl.scrollLeft = nextScrollLeft + return true +} diff --git a/src/utils/sanitizeGroundingHtml.ts b/src/utils/sanitizeGroundingHtml.ts new file mode 100644 index 00000000..f5a41d79 --- /dev/null +++ b/src/utils/sanitizeGroundingHtml.ts @@ -0,0 +1,55 @@ +import { isSafeHttpUrl } from '~/utils/isSafeHttpUrl' + +const SCRIPT_TAG_RE = /]*>)<[^<]*)*<\/script[^>]*>/gi +const SCRIPT_SELF_CLOSING_RE = /]*\/?>/gi +const INLINE_EVENT_HANDLER_RE = /\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi +const ANCHOR_TAG_RE = /]*)>/gi +const HREF_ATTR_RE = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i + +/** Repeat replace until stable — avoids CodeQL incomplete-sanitization bypasses. */ +function replaceUntilStable(html: string, pattern: RegExp): string { + let result = html + let previous = '' + while (result !== previous) { + previous = result + result = result.replace(new RegExp(pattern.source, pattern.flags), '') + } + return result +} + +/** Remove script tags and inline event handlers from API-provided HTML snippets. */ +export function stripExecutableGroundingMarkup(html: string): string { + let result = html + result = replaceUntilStable(result, SCRIPT_TAG_RE) + result = replaceUntilStable(result, SCRIPT_SELF_CLOSING_RE) + result = replaceUntilStable(result, INLINE_EVENT_HANDLER_RE) + result = replaceUntilStable( + result, + /<(?:iframe|object|embed|frame|frameset)\b[^>]*>(?:[\s\S]*?<\/(?:iframe|object|embed|frame|frameset)\s*>)?/gi, + ) + result = replaceUntilStable(result, /<(?:link|meta)\b[^>]*>/gi) + return result +} + +/** Strip unsafe hrefs from anchor tags (native path; web uses DOMParser). */ +export function sanitizeGroundingHtmlLinksRegex(html: string): string { + return html.replace(ANCHOR_TAG_RE, (_, attrs: string) => { + const hrefMatch = attrs.match(HREF_ATTR_RE) + if (!hrefMatch) { + return `` + } + + const href = hrefMatch[1] ?? hrefMatch[2] ?? hrefMatch[3] ?? '' + if (!isSafeHttpUrl(href)) { + const cleanedAttrs = attrs.replace(HREF_ATTR_RE, '').trim() + return cleanedAttrs ? `` : '' + } + + return `` + }) +} + +/** Defense-in-depth sanitization before rendering grounding HTML on native WebViews. */ +export function sanitizeGroundingHtmlForNative(html: string): string { + return sanitizeGroundingHtmlLinksRegex(stripExecutableGroundingMarkup(html)) +}