diff --git a/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts b/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts index 8ac452e0e9..8d3f005273 100644 --- a/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts +++ b/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts @@ -133,6 +133,21 @@ describe('useAskAi', () => { expect(result.current.state.status).toBe('error') }) + it('sends getHeaders on the AG-UI request', async () => { + const answer = { kind: 'grounded', answer: 'A.', sources: [] } + const fetchMock = stubAguiFetch(aguiRun({ middle: structuredDeltas(answer), result: answer })) + + const { result } = renderHook(() => + useAskAi({ getHeaders: () => ({ Authorization: 'Bearer widget-token' }) }) + ) + await act(async () => { + await result.current.ask('q') + }) + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer widget-token') + }) + it('reset returns the hook to idle', async () => { const answer = { kind: 'grounded', answer: 'A.', sources: [{ articleId: 'kb_article_1' }] } stubAguiFetch( diff --git a/apps/web/src/components/help-center/ask-ai.tsx b/apps/web/src/components/help-center/ask-ai.tsx index ffa1bd5890..99323ead4c 100644 --- a/apps/web/src/components/help-center/ask-ai.tsx +++ b/apps/web/src/components/help-center/ask-ai.tsx @@ -39,11 +39,16 @@ export type AskAiSourceMeta = KbAskSourceMeta * Whether Ask AI can be offered: the flag is on AND a model is configured. * Backed by the kb-ask capability probe (404 when flags are off). */ -export function useAskAiAvailable(enabled = true): boolean { +export function useAskAiAvailable( + enabled = true, + options?: { getHeaders?: () => HeadersInit; sessionVersion?: number } +): boolean { const query = useQuery({ - queryKey: ['kb-ask', 'capability'], + queryKey: ['kb-ask', 'capability', options?.sessionVersion ?? 'anon'] as const, queryFn: async () => { - const res = await fetch('/api/widget/kb-ask') + const res = await fetch('/api/widget/kb-ask', { + headers: options?.getHeaders?.(), + }) if (!res.ok) return false const json = (await res.json()) as { data?: { enabled?: boolean } } return json.data?.enabled === true @@ -100,13 +105,10 @@ function toCitations(sources: AskAiSourceMeta[]): ConversationMessageCitation[] const KB_ASK_URL = '/api/widget/kb-ask' -/** Non-2xx widget envelopes (rate limits, flag gates, budget) become a - * synthetic RUN_ERROR SSE frame. Throwing from fetchClient is wrong on AI - * 0.52+: the adapter wraps any rejection as StreamReadError. */ -const askAiFetch = aguiFetchClient() - /** Drive one Ask AI question at a time; re-asking aborts the previous run. */ -export function useAskAi() { +export function useAskAi(options?: { getHeaders?: () => HeadersInit }) { + const getHeadersRef = useRef(options?.getHeaders) + getHeadersRef.current = options?.getHeaders const [state, setState] = useState(IDLE_STATE) const clientRef = useRef(null) @@ -179,7 +181,10 @@ export function useAskAi() { } const client = new ChatClient({ - connection: fetchServerSentEvents(KB_ASK_URL, () => ({ fetchClient: askAiFetch })), + connection: fetchServerSentEvents(KB_ASK_URL, () => ({ + // Non-2xx widget envelopes become a synthetic RUN_ERROR SSE frame. + fetchClient: aguiFetchClient(() => getHeadersRef.current?.()), + })), onChunk: (rawChunk: StreamChunk) => { const chunk = rawChunk as { type: string @@ -269,6 +274,10 @@ export interface AskAiSearchControllerOptions { /** Surface hook fired when the answer panel is dismissed (e.g. reopen the * dropdown for the current query). */ onDismiss?: () => void + /** Widget Bearer (or empty on the portal, which uses cookies). */ + getHeaders?: () => HeadersInit + /** Widget session — reset an open answer when identity changes. */ + sessionVersion?: number } /** @@ -287,8 +296,10 @@ export function useAskAiSearchController({ onClearQuery, onAsk, onDismiss, + getHeaders, + sessionVersion, }: AskAiSearchControllerOptions) { - const { state: askAiState, ask: askAi, reset: resetAskAi } = useAskAi() + const { state: askAiState, ask: askAi, reset: resetAskAi } = useAskAi({ getHeaders }) // Keyboard selection over [ask-ai row, ...results]; -1 = nothing selected. const [selectedIndex, setSelectedIndex] = useState(-1) @@ -303,6 +314,12 @@ export function useAskAiSearchController({ setSelectedIndex(-1) }, [query, resetAskAi]) + // Logout / identify must not leave the previous visitor's cited titles up. + useEffect(() => { + resetAskAi() + setSelectedIndex(-1) + }, [sessionVersion, resetAskAi]) + const triggerAsk = useCallback(() => { if (!hasAskRow) return setSelectedIndex(-1) diff --git a/apps/web/src/components/help-center/use-kb-search.ts b/apps/web/src/components/help-center/use-kb-search.ts index 6eb73c91c5..2cd8a4d399 100644 --- a/apps/web/src/components/help-center/use-kb-search.ts +++ b/apps/web/src/components/help-center/use-kb-search.ts @@ -21,6 +21,8 @@ export function useKbSearch({ query, limit, locale, + sessionVersion, + getHeaders, onResults, }: { query: string @@ -29,6 +31,10 @@ export function useKbSearch({ * (domains/languages §2); an unrecognized/not-enabled locale falls back * to default server-side. */ locale?: string + /** Widget session — included in the cache key and clears hits on change. */ + sessionVersion?: number + /** Widget Bearer (or empty on the portal, which uses cookies). */ + getHeaders?: () => HeadersInit /** Fired with the articles of each completed search (cache hits included); * not fired when the query is blank. */ onResults?: (articles: KbSearchArticle[]) => void @@ -40,15 +46,19 @@ export function useKbSearch({ // Latest callback without retriggering the debounce effect. const onResultsRef = useRef(onResults) onResultsRef.current = onResults + const getHeadersRef = useRef(getHeaders) + getHeadersRef.current = getHeaders + const sessionVersionRef = useRef(sessionVersion) + sessionVersionRef.current = sessionVersion const doSearch = useCallback( - async (q: string, loc: string | undefined) => { + async (q: string, loc: string | undefined, version: number | undefined) => { if (!q.trim()) { setResults([]) return } - const cacheKey = `${loc ?? ''}:${q}` + const cacheKey = `${version ?? ''}:${loc ?? ''}:${q}` const cached = cacheRef.current.get(cacheKey) if (cached) { setResults(cached) @@ -71,7 +81,10 @@ export function useKbSearch({ if (loc) params.set('locale', loc) const res = await fetch(`/api/widget/kb-search?${params.toString()}`, { signal: controller.signal, + headers: getHeadersRef.current?.(), }) + if (!res.ok) return + if (version !== sessionVersionRef.current) return const data = await res.json() const articles: KbSearchArticle[] = data.data?.articles ?? [] cacheRef.current.set(cacheKey, articles) @@ -87,9 +100,17 @@ export function useKbSearch({ ) useEffect(() => { - const timer = setTimeout(() => void doSearch(query, locale), DEBOUNCE_MS) + abortRef.current?.abort() + abortRef.current = null + cacheRef.current.clear() + setResults([]) + setIsSearching(false) + }, [sessionVersion]) + + useEffect(() => { + const timer = setTimeout(() => void doSearch(query, locale, sessionVersion), DEBOUNCE_MS) return () => clearTimeout(timer) - }, [query, locale, doSearch]) + }, [query, locale, sessionVersion, doSearch]) return { results, isSearching } } diff --git a/apps/web/src/components/shared/conversation/visitor-conversation-thread.tsx b/apps/web/src/components/shared/conversation/visitor-conversation-thread.tsx index c41d640467..47433afd8c 100644 --- a/apps/web/src/components/shared/conversation/visitor-conversation-thread.tsx +++ b/apps/web/src/components/shared/conversation/visitor-conversation-thread.tsx @@ -670,6 +670,9 @@ export function VisitorConversationThread({ const [helpResults, setHelpResults] = useState>([]) const helpSearchFn = helpSearch?.search const messageText = composer.text + useEffect(() => { + setHelpResults([]) + }, [helpSearchFn, sessionVersion]) useEffect(() => { if (!helpSearchFn || conversationId || messages.length > 0) { setHelpResults([]) diff --git a/apps/web/src/components/widget/__tests__/build-portal-url.test.ts b/apps/web/src/components/widget/__tests__/build-portal-url.test.ts index fb141f0c0b..88fbf4d1d8 100644 --- a/apps/web/src/components/widget/__tests__/build-portal-url.test.ts +++ b/apps/web/src/components/widget/__tests__/build-portal-url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildPortalUrl } from '../build-portal-url' +import { appendWidgetOtt, buildPortalUrl } from '../build-portal-url' describe('buildPortalUrl', () => { const baseUrl = 'https://feedback.example.com' @@ -63,3 +63,23 @@ describe('buildPortalUrl', () => { expect(url).toContain('?ott=token%2Bwith%2Fspecial%3Dchars') }) }) + +describe('appendWidgetOtt', () => { + it('appends ott when identified', () => { + expect( + appendWidgetOtt('https://feedback.example.com/hc/articles/getting-started/faq', true, 'ott-1') + ).toBe('https://feedback.example.com/hc/articles/getting-started/faq?ott=ott-1') + }) + + it('leaves the URL unchanged for anonymous visitors', () => { + expect( + appendWidgetOtt('https://feedback.example.com/changelog/changelog_1', false, 'ott-1') + ).toBe('https://feedback.example.com/changelog/changelog_1') + }) + + it('leaves the URL unchanged when OTT generation returned null', () => { + expect(appendWidgetOtt('https://feedback.example.com/changelog/changelog_1', true, null)).toBe( + 'https://feedback.example.com/changelog/changelog_1' + ) + }) +}) diff --git a/apps/web/src/components/widget/__tests__/widget-changelog-query.test.ts b/apps/web/src/components/widget/__tests__/widget-changelog-query.test.ts new file mode 100644 index 0000000000..84074dcfb8 --- /dev/null +++ b/apps/web/src/components/widget/__tests__/widget-changelog-query.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' +import { shouldClearUnavailableChangelogCategory } from '../widget-changelog-query' + +describe('shouldClearUnavailableChangelogCategory', () => { + const ready = { sessionVersion: 1, listReady: true, stillLooking: false } + + it('clears a filter the current session feed no longer contains', () => { + expect(shouldClearUnavailableChangelogCategory('secret', [{ id: 'public' }], ready)).toBe(true) + expect(shouldClearUnavailableChangelogCategory('public', [{ id: 'public' }], ready)).toBe(false) + expect(shouldClearUnavailableChangelogCategory(null, [{ id: 'public' }], ready)).toBe(false) + }) + + it('waits until the feed is in and lookahead has finished', () => { + expect( + shouldClearUnavailableChangelogCategory('secret', [], { + ...ready, + listReady: false, + }) + ).toBe(false) + expect( + shouldClearUnavailableChangelogCategory('secret', [], { + ...ready, + stillLooking: true, + }) + ).toBe(false) + }) + + it('does not bounce on the anonymous first paint', () => { + expect( + shouldClearUnavailableChangelogCategory('secret', [], { + ...ready, + sessionVersion: INITIAL_SESSION_VERSION, + }) + ).toBe(false) + }) +}) diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts new file mode 100644 index 0000000000..822a5445e9 --- /dev/null +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from 'vitest' +import { generateId } from '@quackback/ids' +import { + composeBodyFromPlainText, + isArticleTypeId, + resolveComposeBoardId, + resolveOpenCommand, + shouldClearInvisibleBoardFilter, + shouldResetComposeBoard, + shouldReapplyComposeBoard, +} from '../widget-compose' +import type { EnabledTabs } from '../widget-nav' + +const boards = [ + { id: 'board_ideas', slug: 'ideas' }, + { id: 'board_bugs', slug: 'bug-reports' }, +] + +const allTabs: EnabledTabs = { + feedback: true, + changelog: true, + help: true, + messages: true, + tickets: true, + home: true, +} + +describe('resolveComposeBoardId', () => { + it('selects the requested slug when it is on the visible list', () => { + expect(resolveComposeBoardId(boards, 'bug-reports', 'ideas')).toBe('board_bugs') + }) + + it('falls back to the configured default when no slug is given', () => { + expect(resolveComposeBoardId(boards, undefined, 'ideas')).toBe('board_ideas') + }) + + it('falls back to the configured default when the slug is unknown', () => { + expect(resolveComposeBoardId(boards, 'secret-board', 'ideas')).toBe('board_ideas') + }) + + it('does not select a board that is not on the visible list', () => { + expect(resolveComposeBoardId(boards, 'secret-board', undefined)).toBe('') + }) + + it('auto-selects the only board when no slug or default applies', () => { + expect(resolveComposeBoardId([boards[1]], undefined, undefined)).toBe('board_bugs') + expect(resolveComposeBoardId([boards[1]], 'missing', 'also-missing')).toBe('board_bugs') + }) + + it('leaves the picker empty when several boards have no default', () => { + expect(resolveComposeBoardId(boards, undefined, undefined)).toBe('') + }) +}) + +describe('resolveOpenCommand', () => { + it('opens a new-post compose with title, body, and board', () => { + expect( + resolveOpenCommand( + { view: 'new-post', title: 'Bug:', body: 'steps', board: 'bug-reports' }, + allTabs + ) + ).toEqual({ + type: 'new-post', + title: 'Bug:', + body: 'steps', + boardSlug: 'bug-reports', + }) + }) + + it('opens new-post with default-board behaviour when board is omitted', () => { + expect(resolveOpenCommand({ view: 'new-post' }, allTabs)).toEqual({ + type: 'new-post', + title: undefined, + body: undefined, + boardSlug: undefined, + }) + }) + + it('does not open new-post when Feedback is off', () => { + expect(resolveOpenCommand({ view: 'new-post', board: 'bugs' }, { help: true })).toBeNull() + }) + + it('deep-links a post when Feedback is on', () => { + expect(resolveOpenCommand({ postId: 'post_01h' }, allTabs)).toEqual({ + type: 'post', + postId: 'post_01h', + }) + }) + + it('does not deep-link a post when Feedback is off', () => { + expect(resolveOpenCommand({ postId: 'post_01h' }, { changelog: true })).toBeNull() + }) + + it('deep-links a help article when Help is on', () => { + expect(resolveOpenCommand({ articleId: 'pricing' }, allTabs)).toEqual({ + type: 'article', + articleId: 'pricing', + }) + }) + + it('forwards an article TypeID the same way as a post TypeID', () => { + const articleId = generateId('kb_article') + const publicId = `article_${articleId.slice('kb_article_'.length)}` + expect(resolveOpenCommand({ articleId: publicId }, allTabs)).toEqual({ + type: 'article', + articleId: publicId, + }) + expect(isArticleTypeId(publicId)).toBe(true) + expect(isArticleTypeId(articleId)).toBe(true) + expect(isArticleTypeId('art_01h...')).toBe(false) + expect(isArticleTypeId('pricing')).toBe(false) + }) + + it('prefills help search and opens a changelog entry', () => { + expect(resolveOpenCommand({ view: 'help', query: 'pricing' }, allTabs)).toEqual({ + type: 'help', + query: 'pricing', + }) + expect(resolveOpenCommand({ view: 'changelog', entryId: 'chg_01h' }, allTabs)).toEqual({ + type: 'changelog', + entryId: 'chg_01h', + }) + }) + + it('opens chat aliases on the messenger and tickets with a messages fallback', () => { + expect(resolveOpenCommand({ view: 'chat' }, allTabs)).toEqual({ type: 'messenger' }) + expect(resolveOpenCommand({ view: 'live-chat' }, allTabs)).toEqual({ type: 'messenger' }) + expect(resolveOpenCommand({ view: 'tickets' }, { tickets: true })).toEqual({ type: 'tickets' }) + expect(resolveOpenCommand({ view: 'tickets' }, { messages: true })).toEqual({ + type: 'messages', + }) + expect(resolveOpenCommand({ view: 'chat' }, { feedback: true })).toBeNull() + }) + + it('opens home for an empty payload when Home is enabled', () => { + expect(resolveOpenCommand({}, allTabs)).toEqual({ type: 'home' }) + expect(resolveOpenCommand({ view: 'home' }, allTabs)).toEqual({ type: 'home' }) + expect(resolveOpenCommand({}, { feedback: true })).toBeNull() + }) + + it('lets postId and articleId win over view', () => { + expect( + resolveOpenCommand({ view: 'new-post', postId: 'post_01h', title: 'Bug:' }, allTabs) + ).toEqual({ type: 'post', postId: 'post_01h' }) + expect( + resolveOpenCommand({ view: 'new-post', articleId: 'pricing', title: 'Bug:' }, allTabs) + ).toEqual({ type: 'article', articleId: 'pricing' }) + expect( + resolveOpenCommand({ view: 'help', postId: 'post_01h', articleId: 'pricing' }, allTabs) + ).toEqual({ type: 'post', postId: 'post_01h' }) + }) +}) + +describe('shouldResetComposeBoard', () => { + it('resets when the selected board is missing from the confirmed list', () => { + expect(shouldResetComposeBoard('board_secret', boards, ['ideas', 'bug-reports'])).toBe(true) + expect(shouldResetComposeBoard('board_ideas', boards, ['ideas', 'bug-reports'])).toBe(false) + expect(shouldResetComposeBoard('board_ideas', boards, null)).toBe(false) + expect(shouldResetComposeBoard('', boards, ['ideas'])).toBe(false) + }) +}) + +describe('shouldClearInvisibleBoardFilter', () => { + it('clears a selected slug that the live session list no longer contains', () => { + expect(shouldClearInvisibleBoardFilter('secret', ['ideas', 'bugs'])).toBe(true) + expect(shouldClearInvisibleBoardFilter('ideas', ['ideas', 'bugs'])).toBe(false) + expect(shouldClearInvisibleBoardFilter('secret', null)).toBe(false) + expect(shouldClearInvisibleBoardFilter(null, ['ideas'])).toBe(false) + }) +}) + +describe('shouldReapplyComposeBoard', () => { + it('re-applies only when identify newly grants the requested slug', () => { + expect(shouldReapplyComposeBoard('bugs', new Set(), new Set(['bugs', 'ideas']))).toBe(true) + expect(shouldReapplyComposeBoard('bugs', new Set(['bugs']), new Set(['bugs', 'ideas']))).toBe( + false + ) + expect(shouldReapplyComposeBoard('secret', new Set(['bugs']), new Set(['bugs', 'ideas']))).toBe( + false + ) + expect(shouldReapplyComposeBoard(undefined, new Set(), new Set(['bugs']))).toBe(false) + }) + + it('does not overwrite a board the visitor picked after open()', () => { + expect( + shouldReapplyComposeBoard('secret', new Set(['ideas']), new Set(['ideas', 'secret']), true) + ).toBe(false) + expect( + shouldReapplyComposeBoard('secret', new Set(['ideas']), new Set(['ideas', 'secret']), false) + ).toBe(true) + }) +}) + +describe('composeBodyFromPlainText', () => { + it('turns each line into a paragraph', () => { + const { json, html } = composeBodyFromPlainText('one\ntwo') + expect(json).toEqual({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'one' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'two' }] }, + ], + }) + expect(html).toContain('one') + expect(html).toContain('two') + }) +}) diff --git a/apps/web/src/components/widget/__tests__/widget-help-query.test.ts b/apps/web/src/components/widget/__tests__/widget-help-query.test.ts new file mode 100644 index 0000000000..30909e8ee0 --- /dev/null +++ b/apps/web/src/components/widget/__tests__/widget-help-query.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' +import { shouldLeaveUnavailableHelpCategory } from '../widget-help-query' + +describe('shouldLeaveUnavailableHelpCategory', () => { + it('leaves once the live session list no longer contains the id', () => { + expect(shouldLeaveUnavailableHelpCategory('secret', [{ id: 'public' }], 1)).toBe(true) + expect(shouldLeaveUnavailableHelpCategory('public', [{ id: 'public' }], 1)).toBe(false) + }) + + it('waits until the current session list is in', () => { + expect(shouldLeaveUnavailableHelpCategory('secret', undefined, 1)).toBe(false) + }) + + it('does not bounce on the anonymous first paint', () => { + expect( + shouldLeaveUnavailableHelpCategory('secret', [{ id: 'public' }], INITIAL_SESSION_VERSION) + ).toBe(false) + }) +}) diff --git a/apps/web/src/components/widget/build-portal-url.ts b/apps/web/src/components/widget/build-portal-url.ts index 85ea88579e..1fb4444a26 100644 --- a/apps/web/src/components/widget/build-portal-url.ts +++ b/apps/web/src/components/widget/build-portal-url.ts @@ -19,3 +19,11 @@ export function buildPortalUrl(params: { } return url } + +/** Append an OTT to a portal URL when the widget visitor is identified. */ +export function appendWidgetOtt(url: string, isIdentified: boolean, ott: string | null): string { + if (!isIdentified || !ott) return url + const next = new URL(url) + next.searchParams.set('ott', ott) + return next.toString() +} diff --git a/apps/web/src/components/widget/use-changelog-unread.ts b/apps/web/src/components/widget/use-changelog-unread.ts index 1114f231f3..24086061bf 100644 --- a/apps/web/src/components/widget/use-changelog-unread.ts +++ b/apps/web/src/components/widget/use-changelog-unread.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' -import { publicChangelogQueries } from '@/lib/client/queries/changelog' +import { widgetChangelogListQuery } from './widget-changelog-query' +import { useWidgetAuth } from './widget-auth-provider' import { countUnreadChangelogs, getChangelogSeenAt, @@ -18,8 +19,9 @@ export function useChangelogUnread(enabled: boolean): { unread: number markSeen: (publishedAt: string) => void } { + const { sessionVersion } = useWidgetAuth() const { data } = useInfiniteQuery({ - ...publicChangelogQueries.list(), + ...widgetChangelogListQuery(sessionVersion), enabled, refetchInterval: 60_000, }) diff --git a/apps/web/src/components/widget/widget-changelog-detail.tsx b/apps/web/src/components/widget/widget-changelog-detail.tsx index 2c7d7ba563..9a2fb1457d 100644 --- a/apps/web/src/components/widget/widget-changelog-detail.tsx +++ b/apps/web/src/components/widget/widget-changelog-detail.tsx @@ -2,7 +2,10 @@ import { useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { FormattedMessage } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' -import { publicChangelogQueries } from '@/lib/client/queries/changelog' +import { getPublicChangelogFn } from '@/lib/server/functions/changelog' +import { generateOneTimeToken, getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { appendWidgetOtt } from './build-portal-url' +import { widgetQueryKeys, widgetQueryKeyEquals } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import { EmbedHydration } from '@/components/shared/embed-hydration' import type { ChangelogId } from '@quackback/ids' @@ -11,20 +14,42 @@ import { WidgetPortalTitle } from './widget-portal-title' import { sendToHost } from '@/lib/client/widget-bridge' import { WidgetArticleSkeleton } from './widget-skeletons' import { ChangelogMetaRow } from './widget-changelog-meta' +import { useWidgetAuth } from './widget-auth-provider' interface WidgetChangelogDetailProps { entryId: string } export function WidgetChangelogDetail({ entryId }: WidgetChangelogDetailProps) { - const { data: entry, isLoading } = useQuery(publicChangelogQueries.detail(entryId as ChangelogId)) + const { isIdentified, sessionVersion } = useWidgetAuth() + const { data: entry, isLoading } = useQuery({ + queryKey: widgetQueryKeys.changelogDetail.byId(entryId, sessionVersion), + queryFn: () => + getPublicChangelogFn({ + data: { id: entryId as ChangelogId }, + headers: getWidgetAuthHeaders(), + }), + placeholderData: (prev, prevQuery) => + widgetQueryKeyEquals( + widgetQueryKeys.changelogDetail.byId(entryId, sessionVersion), + prevQuery?.queryKey + ) + ? prev + : undefined, + staleTime: 30 * 1000, + }) const changelogEntryId = entry?.id - const handleViewOnPortal = useCallback(() => { + const handleViewOnPortal = useCallback(async () => { if (!changelogEntryId) return - const url = `${window.location.origin}/changelog/${changelogEntryId}` + const ott = isIdentified ? await generateOneTimeToken() : null + const url = appendWidgetOtt( + `${window.location.origin}/changelog/${changelogEntryId}`, + isIdentified, + ott + ) sendToHost({ type: 'quackback:navigate', url }) - }, [changelogEntryId]) + }, [changelogEntryId, isIdentified]) if (isLoading) { return diff --git a/apps/web/src/components/widget/widget-changelog-query.ts b/apps/web/src/components/widget/widget-changelog-query.ts new file mode 100644 index 0000000000..b111a61f7f --- /dev/null +++ b/apps/web/src/components/widget/widget-changelog-query.ts @@ -0,0 +1,40 @@ +import { infiniteQueryOptions } from '@tanstack/react-query' +import { listPublicChangelogsFn } from '@/lib/server/functions/changelog' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { INITIAL_SESSION_VERSION, widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' + +const STALE_TIME_MEDIUM = 60 * 1000 + +/** Identity-aware changelog feed for the widget (Bearer + sessionVersion). */ +export function widgetChangelogListQuery(sessionVersion: number) { + return infiniteQueryOptions({ + queryKey: widgetQueryKeys.changelogList.bySession(sessionVersion), + queryFn: ({ pageParam }) => + listPublicChangelogsFn({ + data: { + cursor: pageParam, + limit: 10, + }, + headers: getWidgetAuthHeaders(), + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + staleTime: STALE_TIME_MEDIUM, + }) +} + +/** + * Drop a changelog category the current session's visible entries no longer + * contain. Wait until the feed is in and lookahead has finished, and skip + * the anonymous first paint so identify can still grant a gated segment. + */ +export function shouldClearUnavailableChangelogCategory( + activeCategoryId: string | null, + categoriesInUse: ReadonlyArray<{ id: string }>, + options: { sessionVersion: number; listReady: boolean; stillLooking: boolean } +): boolean { + if (options.sessionVersion === INITIAL_SESSION_VERSION) return false + if (!options.listReady || options.stillLooking) return false + if (!activeCategoryId) return false + return !categoriesInUse.some((c) => c.id === activeCategoryId) +} diff --git a/apps/web/src/components/widget/widget-changelog-teaser.tsx b/apps/web/src/components/widget/widget-changelog-teaser.tsx index b096d5c7a3..4c94222743 100644 --- a/apps/web/src/components/widget/widget-changelog-teaser.tsx +++ b/apps/web/src/components/widget/widget-changelog-teaser.tsx @@ -1,7 +1,8 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { FormattedMessage } from 'react-intl' import { contentPreview } from '@/lib/shared/utils/string' -import { publicChangelogQueries } from '@/lib/client/queries/changelog' +import { widgetChangelogListQuery } from './widget-changelog-query' +import { useWidgetAuth } from './widget-auth-provider' function formatDate(iso: string) { return new Date(iso).toLocaleDateString('en-US', { @@ -26,7 +27,8 @@ interface WidgetChangelogTeaserProps { * they never disagree about whether content exists. */ export function WidgetChangelogTeaser({ onOpenEntry, onSeeAll }: WidgetChangelogTeaserProps) { - const { data } = useInfiniteQuery(publicChangelogQueries.list()) + const { sessionVersion } = useWidgetAuth() + const { data } = useInfiniteQuery(widgetChangelogListQuery(sessionVersion)) const latest = data?.pages[0]?.items[0] if (!latest) return null diff --git a/apps/web/src/components/widget/widget-changelog.tsx b/apps/web/src/components/widget/widget-changelog.tsx index 4f23dcd76d..685b36ac87 100644 --- a/apps/web/src/components/widget/widget-changelog.tsx +++ b/apps/web/src/components/widget/widget-changelog.tsx @@ -4,7 +4,12 @@ import { FormattedMessage } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' import { contentPreview } from '@/lib/shared/utils/string' import { cn } from '@/lib/shared/utils' -import { publicChangelogQueries, changelogCategoryQueries } from '@/lib/client/queries/changelog' +import { changelogCategoryQueries } from '@/lib/client/queries/changelog' +import { + shouldClearUnavailableChangelogCategory, + widgetChangelogListQuery, +} from './widget-changelog-query' +import { useWidgetAuth } from './widget-auth-provider' import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll' import { getChangelogSeenAt, markChangelogSeen } from './changelog-unread' import { NewspaperIcon } from '@heroicons/react/24/outline' @@ -42,8 +47,9 @@ const lastVisit: { const FILTER_LOOKAHEAD_MIN_ROWS = 3 export function WidgetChangelog({ teamName, onEntrySelect }: WidgetChangelogProps) { + const { sessionVersion } = useWidgetAuth() const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError, isLoading } = - useInfiniteQuery(publicChangelogQueries.list()) + useInfiniteQuery(widgetChangelogListQuery(sessionVersion)) const { data: categories = [] } = useQuery(changelogCategoryQueries.list()) const [activeCategoryId, setActiveCategoryId] = useState( lastVisit.categoryId @@ -103,6 +109,27 @@ export function WidgetChangelog({ teamName, onEntrySelect }: WidgetChangelogProp if (filteredLookahead && !isFetchingNextPage) void fetchNextPage() }, [filteredLookahead, isFetchingNextPage, fetchNextPage]) + useEffect(() => { + if ( + !shouldClearUnavailableChangelogCategory(activeCategoryId, categoriesInUse, { + sessionVersion, + listReady: !isLoading && data !== undefined, + stillLooking: filteredLookahead || isFetchingNextPage, + }) + ) { + return + } + setActiveCategoryId(null) + }, [ + activeCategoryId, + categoriesInUse, + data, + filteredLookahead, + isFetchingNextPage, + isLoading, + sessionVersion, + ]) + // Scroll restore: put the viewport back where it was once the (cached) list // has painted, then track every scroll so the next visit can do the same. const viewportRef = useRef(null) diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts new file mode 100644 index 0000000000..da4d66928e --- /dev/null +++ b/apps/web/src/components/widget/widget-compose.ts @@ -0,0 +1,168 @@ +import type { JSONContent } from '@tiptap/core' +import { generateContentHTML } from '@/lib/shared/content-html' +import { isArticleTypeId } from '@/lib/shared/widget/article-ref' +import { homeEnabled, type EnabledTabs } from './widget-nav' + +export { isArticleTypeId } + +export interface WidgetComposeRequest { + /** Bumped on every programmatic open so the same title/board can re-apply. */ + nonce: number + title?: string + body?: string + boardSlug?: string +} + +export type WidgetOpenPayload = { + view?: string + title?: string + body?: string + board?: string + query?: string + entryId?: string + postId?: string + articleId?: string +} + +export type WidgetOpenCommand = + | { type: 'new-post'; title?: string; body?: string; boardSlug?: string } + | { type: 'post'; postId: string } + | { type: 'article'; articleId: string } // slug or `article_` / `kb_article_` TypeID + | { type: 'changelog'; entryId?: string } + | { type: 'help'; query?: string } + | { type: 'messenger' } + | { type: 'tickets' } + | { type: 'messages' } + | { type: 'home' } + +/** + * Map an SDK `open(...)` payload to an iframe command. Unknown or unauthorized + * targets return null — the panel is already open; do not invent a surface. + * + * `postId` and `articleId` win over `view` so a deep-link is never swallowed + * by a leftover compose/home view on the same payload. + */ +export function resolveOpenCommand( + opts: WidgetOpenPayload, + tabs: EnabledTabs +): WidgetOpenCommand | null { + if (nonEmpty(opts.postId)) { + return tabs.feedback ? { type: 'post', postId: opts.postId } : null + } + if (nonEmpty(opts.articleId)) { + return tabs.help ? { type: 'article', articleId: opts.articleId } : null + } + + switch (opts.view) { + case 'new-post': + if (!tabs.feedback) return null + return { + type: 'new-post', + title: emptyToUndef(opts.title), + body: emptyToUndef(opts.body), + boardSlug: emptyToUndef(opts.board), + } + case 'changelog': + if (!tabs.changelog) return null + return { type: 'changelog', entryId: emptyToUndef(opts.entryId) } + case 'help': + if (!tabs.help) return null + return { type: 'help', query: emptyToUndef(opts.query) } + case 'messages': + case 'chat': + case 'live-chat': + return tabs.messages ? { type: 'messenger' } : null + case 'tickets': + if (tabs.tickets) return { type: 'tickets' } + if (tabs.messages) return { type: 'messages' } + return null + case 'home': + case 'overview': + case undefined: + return homeEnabled(tabs) ? { type: 'home' } : null + default: + return null + } +} + +/** + * Resolve a compose-form board. Only slugs already on the visitor-visible + * `boards` list (boardViewFilter) can win — never invent access. An unknown + * or omitted slug uses the same fallback as a normal form mount: configured + * default, else the only board, else empty (picker). + */ +export function resolveComposeBoardId( + boards: ReadonlyArray<{ id: string; slug: string }>, + requestedSlug: string | undefined, + defaultBoardSlug: string | undefined +): string { + if (requestedSlug) { + const requested = boards.find((b) => b.slug === requestedSlug) + if (requested) return requested.id + } + if (defaultBoardSlug) { + const fallback = boards.find((b) => b.slug === defaultBoardSlug) + if (fallback) return fallback.id + } + if (boards.length === 1) return boards[0].id + return '' +} + +/** Drop a compose selection the current session can no longer see. */ +export function shouldResetComposeBoard( + selectedBoardId: string, + boards: ReadonlyArray<{ id: string; slug: string }>, + confirmedBoardSlugs: readonly string[] | null | undefined +): boolean { + if (!selectedBoardId || !confirmedBoardSlugs) return false + const selected = boards.find((b) => b.id === selectedBoardId) + return !selected || !confirmedBoardSlugs.includes(selected.slug) +} + +/** Drop a Popular Ideas filter the current session can no longer see. */ +export function shouldClearInvisibleBoardFilter( + activeBoardSlug: string | null, + confirmedBoardSlugs: readonly string[] | null | undefined +): boolean { + return ( + !!activeBoardSlug && !!confirmedBoardSlugs && !confirmedBoardSlugs.includes(activeBoardSlug) + ) +} + +/** + * Re-apply `open({ board })` only when identify just granted that slug + * and the visitor has not picked another board since the compose request. + */ +export function shouldReapplyComposeBoard( + requestedSlug: string | undefined, + previousSlugs: ReadonlySet, + nextSlugs: ReadonlySet, + selectionDirty = false +): boolean { + if (selectionDirty || !requestedSlug) return false + return nextSlugs.has(requestedSlug) && !previousSlugs.has(requestedSlug) +} + +/** Plain-text `body` from the host → a one-paragraph-per-line TipTap doc. */ +export function composeBodyFromPlainText(body: string): { json: JSONContent; html: string } { + const lines = body.replace(/\r\n/g, '\n').split('\n') + const json: JSONContent = { + type: 'doc', + content: + lines.length === 0 + ? [{ type: 'paragraph' }] + : lines.map((line) => ({ + type: 'paragraph', + ...(line ? { content: [{ type: 'text', text: line }] } : {}), + })), + } + return { json, html: generateContentHTML(json) } +} + +function nonEmpty(value: string | undefined): value is string { + return typeof value === 'string' && value.length > 0 +} + +function emptyToUndef(value: string | undefined): string | undefined { + return nonEmpty(value) ? value : undefined +} diff --git a/apps/web/src/components/widget/widget-help-category.tsx b/apps/web/src/components/widget/widget-help-category.tsx index bf2b293312..d9301b7534 100644 --- a/apps/web/src/components/widget/widget-help-category.tsx +++ b/apps/web/src/components/widget/widget-help-category.tsx @@ -1,16 +1,24 @@ +import { useEffect } from 'react' import { useQuery } from '@tanstack/react-query' -import { FormattedMessage } from 'react-intl' +import { FormattedMessage, useIntl } from 'react-intl' import { ScrollArea } from '@/components/ui/scroll-area' import { ChevronRightIcon } from '@heroicons/react/24/solid' -import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' import { CategoryIcon } from '@/components/help-center/category-icon' import { WidgetHelpArticleListSkeleton } from './widget-skeletons' +import { + shouldLeaveUnavailableHelpCategory, + widgetHelpCategoriesQuery, + widgetHelpCategoryArticlesQuery, +} from './widget-help-query' +import { useWidgetAuth } from './widget-auth-provider' interface WidgetHelpCategoryProps { categoryId: string categoryName: string categoryIcon: string | null onArticleSelect: (articleSlug: string) => void + /** Identity change dropped this collection — return to Help. */ + onCategoryUnavailable?: () => void } export function WidgetHelpCategory({ @@ -18,13 +26,32 @@ export function WidgetHelpCategory({ categoryName, categoryIcon, onArticleSelect, + onCategoryUnavailable, }: WidgetHelpCategoryProps) { - const articlesQuery = useQuery(publicHelpCenterQueries.articlesForCategory(categoryId)) + const { locale } = useIntl() + const { sessionVersion } = useWidgetAuth() + const articlesQuery = useQuery( + widgetHelpCategoryArticlesQuery(categoryId, sessionVersion, locale) + ) // The collection list already has every category's description and icon; // read them from that cache so the header carries context (and an icon even // when we arrived from an article's eyebrow, which only knows id + name). - const categoriesQuery = useQuery(publicHelpCenterQueries.categories()) + const categoriesQuery = useQuery(widgetHelpCategoriesQuery(sessionVersion, locale)) const category = categoriesQuery.data?.find((c) => c.id === categoryId) + + useEffect(() => { + if (!onCategoryUnavailable || !categoriesQuery.isSuccess) return + if (!shouldLeaveUnavailableHelpCategory(categoryId, categoriesQuery.data, sessionVersion)) { + return + } + onCategoryUnavailable() + }, [ + categoriesQuery.data, + categoriesQuery.isSuccess, + categoryId, + onCategoryUnavailable, + sessionVersion, + ]) const icon = categoryIcon ?? category?.icon ?? null const articleCount = articlesQuery.data?.length ?? category?.articleCount diff --git a/apps/web/src/components/widget/widget-help-detail.tsx b/apps/web/src/components/widget/widget-help-detail.tsx index 4a3b2dceb3..18ffe9b40d 100644 --- a/apps/web/src/components/widget/widget-help-detail.tsx +++ b/apps/web/src/components/widget/widget-help-detail.tsx @@ -1,18 +1,24 @@ import { useCallback } from 'react' import { useQuery } from '@tanstack/react-query' -import { FormattedMessage } from 'react-intl' +import { FormattedMessage, useIntl } from 'react-intl' import { ChevronRightIcon } from '@heroicons/react/24/outline' import { ScrollArea } from '@/components/ui/scroll-area' -import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' +import { resolvePublicArticleRefFn } from '@/lib/server/functions/help-center' +import { generateOneTimeToken, getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { appendWidgetOtt } from './build-portal-url' +import { hcArticlePath } from '@/lib/shared/help-center-url' +import { widgetQueryKeys, widgetQueryKeyEquals } from '@/lib/client/hooks/use-widget-vote' import { RichTextContent, isRichTextContent } from '@/components/ui/rich-text-content' import type { JSONContent } from '@tiptap/react' import { WidgetPortalTitle } from './widget-portal-title' import { WidgetArticleFooter } from './widget-article-footer' import { sendToHost } from '@/lib/client/widget-bridge' import { WidgetArticleSkeleton } from './widget-skeletons' +import { useWidgetAuth } from './widget-auth-provider' interface WidgetHelpDetailProps { - articleSlug: string + /** `article_` / `kb_article_` TypeID or public slug — same as `open({ articleId })`. */ + articleRef: string /** Tapping the category eyebrow browses the rest of that collection. */ onCategorySelect?: (categoryId: string, categoryName: string) => void /** "Still stuck?" exit ramp — opens a new conversation. Omitted when the @@ -21,17 +27,43 @@ interface WidgetHelpDetailProps { } export function WidgetHelpDetail({ - articleSlug, + articleRef, onCategorySelect, onAskQuestion, }: WidgetHelpDetailProps) { - const { data: article, isLoading } = useQuery(publicHelpCenterQueries.articleBySlug(articleSlug)) + const { isIdentified, sessionVersion } = useWidgetAuth() + const { locale } = useIntl() + const { data: article, isLoading } = useQuery({ + queryKey: widgetQueryKeys.articleDetail.byRef(articleRef, sessionVersion, locale), + queryFn: () => + resolvePublicArticleRefFn({ + data: { ref: articleRef, locale }, + headers: getWidgetAuthHeaders(), + }), + placeholderData: (prev, prevQuery) => + widgetQueryKeyEquals( + widgetQueryKeys.articleDetail.byRef(articleRef, sessionVersion, locale), + prevQuery?.queryKey + ) + ? prev + : undefined, + staleTime: 30 * 1000, + }) - const handleViewOnPortal = useCallback(() => { + const handleViewOnPortal = useCallback(async () => { if (!article) return - const url = `${window.location.origin}/hc/articles/${article.category.slug}/${article.slug}` + const ott = isIdentified ? await generateOneTimeToken() : null + const url = appendWidgetOtt( + `${window.location.origin}${hcArticlePath({ + locale: article.resolvedLocale, + urlId: article.urlId, + slug: article.slug, + })}`, + isIdentified, + ott + ) sendToHost({ type: 'quackback:navigate', url }) - }, [article]) + }, [article, isIdentified]) if (isLoading) { return diff --git a/apps/web/src/components/widget/widget-help-query.ts b/apps/web/src/components/widget/widget-help-query.ts new file mode 100644 index 0000000000..3491257fc0 --- /dev/null +++ b/apps/web/src/components/widget/widget-help-query.ts @@ -0,0 +1,53 @@ +import { queryOptions } from '@tanstack/react-query' +import { + listPublicArticlesForCategoryFn, + listPublicCategoriesFn, +} from '@/lib/server/functions/help-center' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' +import { INITIAL_SESSION_VERSION, widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' + +const STALE_TIME_MEDIUM = 60 * 1000 + +/** Identity-aware help collections for the widget (Bearer + sessionVersion). */ +export function widgetHelpCategoriesQuery(sessionVersion: number, locale: string) { + return queryOptions({ + queryKey: widgetQueryKeys.helpCategories.bySession(sessionVersion, locale), + queryFn: () => + listPublicCategoriesFn({ + data: { locale }, + headers: getWidgetAuthHeaders(), + }), + staleTime: STALE_TIME_MEDIUM, + }) +} + +/** Identity-aware articles in one collection (Bearer + sessionVersion). */ +export function widgetHelpCategoryArticlesQuery( + categoryId: string, + sessionVersion: number, + locale: string +) { + return queryOptions({ + queryKey: widgetQueryKeys.helpCategoryArticles.byCategory(categoryId, sessionVersion, locale), + queryFn: () => + listPublicArticlesForCategoryFn({ + data: { categoryId, locale }, + headers: getWidgetAuthHeaders(), + }), + staleTime: STALE_TIME_MEDIUM, + }) +} + +/** + * Leave a stored collection once this session's list is in and no longer + * contains it. Skip the anonymous first paint so identify can still grant + * a members-only category the visitor arrived on. + */ +export function shouldLeaveUnavailableHelpCategory( + categoryId: string, + categories: ReadonlyArray<{ id: string }> | undefined, + sessionVersion: number +): boolean { + if (sessionVersion === INITIAL_SESSION_VERSION) return false + return !!categories && !categories.some((c) => c.id === categoryId) +} diff --git a/apps/web/src/components/widget/widget-help.tsx b/apps/web/src/components/widget/widget-help.tsx index 1cc546b166..a349e2ecba 100644 --- a/apps/web/src/components/widget/widget-help.tsx +++ b/apps/web/src/components/widget/widget-help.tsx @@ -10,7 +10,9 @@ import { ChevronRightIcon, XMarkIcon, } from '@heroicons/react/24/outline' -import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' +import { widgetHelpCategoriesQuery } from './widget-help-query' +import { useWidgetAuth } from './widget-auth-provider' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { getTopLevelCategories } from '@/components/help-center/help-center-utils' import { CategoryIcon } from '@/components/help-center/category-icon' import { @@ -44,17 +46,27 @@ export function WidgetHelp({ onSearchChange, }: WidgetHelpProps) { const intl = useIntl() + const { sessionVersion } = useWidgetAuth() const [localSearch, setLocalSearch] = useState('') const search = controlledSearch ?? localSearch const setSearch = onSearchChange ?? setLocalSearch - const categoriesQuery = useQuery(publicHelpCenterQueries.categories()) + const categoriesQuery = useQuery(widgetHelpCategoriesQuery(sessionVersion, intl.locale)) const topLevelCategories = categoriesQuery.data ? getTopLevelCategories(categoriesQuery.data) : [] - const askAiAvailable = useAskAiAvailable() + const askAiAvailable = useAskAiAvailable(true, { + getHeaders: getWidgetAuthHeaders, + sessionVersion, + }) // Widget locale passthrough (domains/languages §2): the search API falls // back to the default locale server-side if this locale isn't enabled. - const { results, isSearching } = useKbSearch({ query: search, limit: 10, locale: intl.locale }) + const { results, isSearching } = useKbSearch({ + query: search, + limit: 10, + locale: intl.locale, + sessionVersion, + getHeaders: getWidgetAuthHeaders, + }) // The search hook debounces 300ms before it even starts fetching; during // that window `isSearching` is still false and `results` still belong to // the previous query. Treat "typed but not yet settled" as pending too, so @@ -79,6 +91,8 @@ export function WidgetHelp({ if (article) onArticleSelect?.(article.slug) }, onClearQuery: () => setSearch(''), + getHeaders: getWidgetAuthHeaders, + sessionVersion, }) const showCategories = !search && !isSearching diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 809bff2a8c..64981af118 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -9,7 +9,7 @@ import { ChevronRightIcon, } from '@heroicons/react/24/outline' import { motion, AnimatePresence } from 'framer-motion' -import { useInfiniteQuery, useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { useIntl, FormattedMessage } from 'react-intl' import { Select, @@ -22,7 +22,12 @@ import { listPublicPostsFn } from '@/lib/server/functions/public-posts' import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll' import { WidgetVoteButton } from './widget-vote-button' import { WidgetPostListSkeleton } from './widget-skeletons' -import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote' +import { + widgetQueryKeys, + widgetQueryKeySameSession, + INITIAL_SESSION_VERSION, +} from '@/lib/client/hooks/use-widget-vote' +import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { cn } from '@/lib/shared/utils' import { useWidgetAuth } from './widget-auth-provider' import { sendToHost } from '@/lib/client/widget-bridge' @@ -31,6 +36,14 @@ import { RichTextEditor } from '@/components/ui/rich-text-editor' import { useWidgetImageUpload, WidgetSessionError } from './use-widget-image-upload' import type { JSONContent } from '@tiptap/react' import type { TiptapContent } from '@/lib/shared/schemas/posts' +import { + composeBodyFromPlainText, + resolveComposeBoardId, + shouldClearInvisibleBoardFilter, + shouldResetComposeBoard, + shouldReapplyComposeBoard, + type WidgetComposeRequest, +} from './widget-compose' interface WidgetPost { id: string @@ -79,6 +92,15 @@ export interface WidgetHomeProps { */ boardPermissions?: Record defaultBoard?: string + /** SDK `?board=` / `defaultBoard` — seed the Popular Ideas filter. */ + initialBoardSlug?: string + /** + * Board slugs from the current session's capability fetch. `null` while that + * query has no data yet — do not treat the anonymous SSR fallback as final. + */ + confirmedBoardSlugs?: string[] | null + /** Programmatic `open({ view: 'new-post' })` — expand and prefill. */ + composeRequest?: WidgetComposeRequest | null onPostSelect?: (postId: string) => void onPostCreated?: (post: { id: string @@ -93,8 +115,33 @@ interface SearchResult { posts: WidgetPost[] } +const SIMILAR_SEARCH_CACHE_LIMIT = 40 +let similarSearchCacheVersion = INITIAL_SESSION_VERSION const similarSearchCache = new Map() +function similarSearchCacheFor(sessionVersion: number) { + if (similarSearchCacheVersion !== sessionVersion) { + similarSearchCache.clear() + similarSearchCacheVersion = sessionVersion + } + return similarSearchCache +} + +function similarSearchCacheGet(sessionVersion: number, q: string): SearchResult | undefined { + return similarSearchCacheFor(sessionVersion).get(q) +} + +function similarSearchCacheSet(sessionVersion: number, q: string, result: SearchResult) { + const cache = similarSearchCacheFor(sessionVersion) + if (cache.has(q)) cache.delete(q) + cache.set(q, result) + while (cache.size > SIMILAR_SEARCH_CACHE_LIMIT) { + const oldest = cache.keys().next().value + if (oldest === undefined) break + cache.delete(oldest) + } +} + // ── Shared post row used in both similar-posts and popular-ideas lists ── const WidgetPostRow = memo( @@ -218,6 +265,9 @@ export function WidgetHomeAnimated({ boards, boardPermissions, defaultBoard, + initialBoardSlug, + confirmedBoardSlugs, + composeRequest, onPostSelect, onPostCreated, }: WidgetHomeProps) { @@ -231,22 +281,21 @@ export function WidgetHomeAnimated({ emitEvent, metadata, getSessionVersion, + sessionVersion, } = useWidgetAuth() const queryClient = useQueryClient() const inputRef = useRef(null) const [title, setTitle] = useState('') const [expanded, setExpanded] = useState(false) - const [selectedBoardId, setSelectedBoardId] = useState(() => { - if (defaultBoard) { - const match = boards.find((b) => b.slug === defaultBoard) - if (match) return match.id - } - // Single board: auto-select (selector is hidden anyway). Multiple boards with no - // default: leave empty so the user is prompted to pick one. - if (boards.length === 1) return boards[0].id - return '' - }) + const [selectedBoardId, setSelectedBoardId] = useState(() => + resolveComposeBoardId(boards, undefined, defaultBoard) + ) + const composeBoardDirtyRef = useRef(false) + const handleComposeBoardChange = useCallback((id: string) => { + composeBoardDirtyRef.current = true + setSelectedBoardId(id) + }, []) const [contentJson, setContentJson] = useState(null) const [contentHtml, setContentHtml] = useState('') const handleEditorChange = useCallback((json: JSONContent, html: string) => { @@ -254,6 +303,47 @@ export function WidgetHomeAnimated({ setContentHtml(html) }, []) + // Host `open({ view: 'new-post' })` lands here. Nonce (not title/board) is + // the trigger so a second identical command still expands and reapplies. + useEffect(() => { + if (!composeRequest) return + composeBoardDirtyRef.current = false + setExpanded(true) + if (composeRequest.title) setTitle(composeRequest.title) + if (composeRequest.body) { + const next = composeBodyFromPlainText(composeRequest.body) + setContentJson(next.json) + setContentHtml(next.html) + } + setSelectedBoardId(resolveComposeBoardId(boards, composeRequest.boardSlug, defaultBoard)) + inputRef.current?.focus({ preventScroll: true }) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- nonce is the command identity + }, [composeRequest?.nonce]) + + // Identify can grow the visitor-visible list (members-only slugs). Re-apply + // a requested slug only when it just appeared — not when the visitor already + // picked another board after open(). + const visibleBoardSlugs = useMemo(() => new Set(boards.map((b) => b.slug)), [boards]) + const prevVisibleBoardSlugsRef = useRef>(new Set()) + useEffect(() => { + const next = visibleBoardSlugs + const prev = prevVisibleBoardSlugsRef.current + prevVisibleBoardSlugsRef.current = next + const slug = composeRequest?.boardSlug + if (!shouldReapplyComposeBoard(slug, prev, next, composeBoardDirtyRef.current)) return + const match = boards.find((b) => b.slug === slug) + if (match) setSelectedBoardId(match.id) + }, [visibleBoardSlugs, boards, composeRequest?.boardSlug, composeRequest?.nonce]) + + // After identify/logout the live list is authoritative. Keep a stale + // members-only selection through the anonymous first paint (identify may + // grant it); once this session's fetch lands, fall back to the default. + useEffect(() => { + if (sessionVersion === INITIAL_SESSION_VERSION) return + if (!shouldResetComposeBoard(selectedBoardId, boards, confirmedBoardSlugs)) return + setSelectedBoardId(resolveComposeBoardId(boards, undefined, defaultBoard)) + }, [sessionVersion, selectedBoardId, boards, confirmedBoardSlugs, defaultBoard]) + // Per-board capability, server-computed for the request actor. The widget // route refetches boardPermissions with the Bearer identity (keyed on // sessionVersion), so for an identified viewer this already reflects the real @@ -299,7 +389,16 @@ export function WidgetHomeAnimated({ const [similarPostResults, setSimilarPostResults] = useState(null) const [isSimilarSearching, setIsSimilarSearching] = useState(false) const similarDebounceRef = useRef>(null) - const [activeBoardSlug, setActiveBoardSlug] = useState(null) + const [activeBoardSlug, setActiveBoardSlug] = useState( + () => initialBoardSlug ?? null + ) + // After identify/logout the live board list is authoritative. Keep the SDK + // `?board=` filter through the anonymous first paint (identify may grant it). + useEffect(() => { + if (sessionVersion === INITIAL_SESSION_VERSION) return + if (!shouldClearInvisibleBoardFilter(activeBoardSlug, confirmedBoardSlugs)) return + setActiveBoardSlug(null) + }, [sessionVersion, activeBoardSlug, confirmedBoardSlugs]) const pills = usePillsScroll() const [popularSearch, setPopularSearch] = useState('') const [debouncedPopularSearch, setDebouncedPopularSearch] = useState('') @@ -317,7 +416,7 @@ export function WidgetHomeAnimated({ isFetchingNextPage, isFetching: isFetchingPosts, } = useInfiniteQuery({ - queryKey: ['widget', 'posts', 'popular', 'top', activeBoardSlug ?? 'all'], + queryKey: widgetQueryKeys.popularPosts.list(activeBoardSlug, sessionVersion), queryFn: async ({ pageParam }) => { const page = await listPublicPostsFn({ data: { @@ -326,14 +425,17 @@ export function WidgetHomeAnimated({ limit: 20, boardSlug: activeBoardSlug ?? undefined, }, + headers: getWidgetAuthHeaders(), }) return { ...page, items: page.items.map(toWidgetPost) } }, initialPageParam: 1, getNextPageParam: (lastPage, allPages) => (lastPage.hasMore ? allPages.length + 1 : undefined), - // Only seed from SSR data on the initial unfiltered view + // Seed from SSR only on the anonymous first paint for the same board + // filter the loader used (`?board=` or All). Identify re-keys this + // query so members-only boards refetch with the Bearer actor. initialData: - activeBoardSlug === null + activeBoardSlug === (initialBoardSlug ?? null) && sessionVersion === INITIAL_SESSION_VERSION ? { pages: [{ items: initialPosts, total: undefined, hasMore: initialHasMore }], pageParams: [1], @@ -358,19 +460,26 @@ export function WidgetHomeAnimated({ isFetching: isPopularSearchFetching, isPlaceholderData: isPopularSearchStale, } = useQuery({ - queryKey: ['widget', 'search', 'popular', debouncedPopularSearch, activeBoardSlug ?? 'all'], + queryKey: widgetQueryKeys.popularSearch.query( + debouncedPopularSearch, + activeBoardSlug, + sessionVersion + ), queryFn: async () => { const params = new URLSearchParams({ q: debouncedPopularSearch, limit: '20' }) if (activeBoardSlug) params.set('board', activeBoardSlug) - const res = await fetch(`/api/widget/search?${params}`) + const res = await fetch(`/api/widget/search?${params}`, { + headers: getWidgetAuthHeaders(), + }) const json = await res.json() return { posts: (json.data?.posts ?? []) as WidgetPost[] } }, enabled: debouncedPopularSearch.length > 0, // Refining a query keeps the previous hits on screen (dimmed) instead of - // blinking the list empty between keystrokes; only the very first search - // has nothing to hold and shows the row skeleton. - placeholderData: keepPreviousData, + // blinking the list empty between keystrokes. Drop them when the session + // changes so a later identity never sees the previous visitor's titles. + placeholderData: (prev, prevQuery) => + widgetQueryKeySameSession(prevQuery?.queryKey, sessionVersion) ? prev : undefined, }) // Typed-but-unsettled (debounce window), or fetching, or showing hits that // belong to the previous query. @@ -407,21 +516,30 @@ export function WidgetHomeAnimated({ setIsSimilarSearching(false) return } - const cached = similarSearchCache.get(q) + const cached = similarSearchCacheGet(sessionVersion, q) if (cached) { setSimilarPostResults(cached) setIsSimilarSearching(false) return } + // Drop the previous identity's hits before the new request lands. + setSimilarPostResults(null) setIsSimilarSearching(true) const controller = new AbortController() similarDebounceRef.current = setTimeout(async () => { try { const params = new URLSearchParams({ q, limit: '5' }) - const res = await fetch(`/api/widget/search?${params}`, { signal: controller.signal }) + const res = await fetch(`/api/widget/search?${params}`, { + signal: controller.signal, + headers: getWidgetAuthHeaders(), + }) + if (!res.ok) { + setSimilarPostResults({ posts: [] }) + return + } const json = await res.json() const result: SearchResult = { posts: json.data?.posts ?? [] } - similarSearchCache.set(q, result) + similarSearchCacheSet(sessionVersion, q, result) setSimilarPostResults(result) } catch (err) { if (err instanceof Error && err.name === 'AbortError') return @@ -434,7 +552,7 @@ export function WidgetHomeAnimated({ if (similarDebounceRef.current) clearTimeout(similarDebounceRef.current) controller.abort() } - }, [title]) + }, [title, sessionVersion]) // Debounce popular ideas search useEffect(() => { @@ -601,7 +719,7 @@ export function WidgetHomeAnimated({ defaultMessage="Posting to" /> - { const res = await fetch(`/api/widget/kb-search?q=${encodeURIComponent(q)}&limit=3`, { signal, + headers: getWidgetAuthHeaders(), }) if (!res.ok) return [] const json = (await res.json()) as { @@ -58,7 +59,7 @@ export function WidgetMessenger({ }, onSelect: onArticleSelect, } - }, [helpEnabled, onArticleSelect]) + }, [helpEnabled, onArticleSelect, sessionVersion]) return ( (prevQuery?.queryKey[2] === postId ? prev : undefined), + placeholderData: (prev, prevQuery) => + widgetQueryKeyPrefixEquals([...widgetQueryKeys.postDetail.all, postId], prevQuery?.queryKey) + ? prev + : undefined, staleTime: 30 * 1000, }) diff --git a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts index a3513dfc59..9e7f13d207 100644 --- a/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts +++ b/apps/web/src/lib/client/hooks/__tests__/widget-query-keys.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest' -import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '../use-widget-vote' +import { + widgetQueryKeys, + widgetQueryKeyEquals, + widgetQueryKeyPrefixEquals, + widgetQueryKeySameSession, + INITIAL_SESSION_VERSION, +} from '../use-widget-vote' describe('widgetQueryKeys', () => { describe('votedPosts', () => { @@ -46,6 +52,128 @@ describe('widgetQueryKeys', () => { }) }) + describe('articleDetail', () => { + it('byRef includes ref, locale, and version', () => { + expect(widgetQueryKeys.articleDetail.byRef('article_1', 0, 'en')).toEqual([ + 'widget', + 'article', + 'article_1', + 'en', + 0, + ]) + }) + + it('same ref with different locales produce different keys', () => { + const en = widgetQueryKeys.articleDetail.byRef('article_1', 0, 'en') + const de = widgetQueryKeys.articleDetail.byRef('article_1', 0, 'de') + expect(en).not.toEqual(de) + }) + }) + + describe('changelogDetail', () => { + it('byId includes entryId and version', () => { + expect(widgetQueryKeys.changelogDetail.byId('changelog_1', 2)).toEqual([ + 'widget', + 'changelog', + 'changelog_1', + 2, + ]) + }) + }) + + describe('changelogList', () => { + it('bySession includes version', () => { + expect(widgetQueryKeys.changelogList.all).toEqual(['widget', 'changelogs']) + expect(widgetQueryKeys.changelogList.bySession(0)).toEqual(['widget', 'changelogs', 0]) + expect(widgetQueryKeys.changelogList.bySession(2)).toEqual(['widget', 'changelogs', 2]) + }) + }) + + describe('widgetQueryKeyEquals', () => { + it('matches a factory key without depending on slot indexes', () => { + const key = widgetQueryKeys.articleDetail.byRef('article_1', 3, 'de') + expect( + widgetQueryKeyEquals(widgetQueryKeys.articleDetail.byRef('article_1', 3, 'de'), key) + ).toBe(true) + expect( + widgetQueryKeyEquals(widgetQueryKeys.articleDetail.byRef('article_1', 4, 'de'), key) + ).toBe(false) + expect(widgetQueryKeyEquals(key, undefined)).toBe(false) + }) + + it('prefix match keeps the same entity across trailing key slots', () => { + const key = widgetQueryKeys.postDetail.byId('post_1', 4) + expect(widgetQueryKeyPrefixEquals([...widgetQueryKeys.postDetail.all, 'post_1'], key)).toBe( + true + ) + expect(widgetQueryKeyPrefixEquals([...widgetQueryKeys.postDetail.all, 'post_2'], key)).toBe( + false + ) + }) + }) + + describe('helpCategories', () => { + it('bySession includes locale and version', () => { + expect(widgetQueryKeys.helpCategories.bySession(0, 'en')).toEqual([ + 'widget', + 'help', + 'categories', + 'en', + 0, + ]) + expect(widgetQueryKeys.helpCategories.bySession(2, 'de')).toEqual([ + 'widget', + 'help', + 'categories', + 'de', + 2, + ]) + }) + }) + + describe('helpCategoryArticles', () => { + it('byCategory includes category, locale, and version', () => { + expect(widgetQueryKeys.helpCategoryArticles.byCategory('cat_1', 1, 'en')).toEqual([ + 'widget', + 'help', + 'category-articles', + 'cat_1', + 'en', + 1, + ]) + }) + }) + + describe('widgetQueryKeySameSession', () => { + it('matches when the last key slot is the current session', () => { + const key = widgetQueryKeys.popularSearch.query('bugs', null, 3) + expect(widgetQueryKeySameSession(key, 3)).toBe(true) + expect(widgetQueryKeySameSession(key, 4)).toBe(false) + expect(widgetQueryKeySameSession(undefined, 3)).toBe(false) + }) + }) + + describe('popularPosts', () => { + it('list includes board slug and version', () => { + expect(widgetQueryKeys.popularPosts.list(null, 0)).toEqual([ + 'widget', + 'posts', + 'popular', + 'top', + 'all', + 0, + ]) + expect(widgetQueryKeys.popularPosts.list('bugs', 1)).toEqual([ + 'widget', + 'posts', + 'popular', + 'top', + 'bugs', + 1, + ]) + }) + }) + it('INITIAL_SESSION_VERSION is 0', () => { expect(INITIAL_SESSION_VERSION).toBe(0) }) diff --git a/apps/web/src/lib/client/hooks/use-widget-vote.ts b/apps/web/src/lib/client/hooks/use-widget-vote.ts index b20f4330f6..ebb9042506 100644 --- a/apps/web/src/lib/client/hooks/use-widget-vote.ts +++ b/apps/web/src/lib/client/hooks/use-widget-vote.ts @@ -27,6 +27,61 @@ export const widgetQueryKeys = { all: ['widget', 'post'] as const, byId: (postId: string, version: number) => ['widget', 'post', postId, version] as const, }, + articleDetail: { + all: ['widget', 'article'] as const, + byRef: (ref: string, version: number, locale: string) => + ['widget', 'article', ref, locale, version] as const, + }, + changelogDetail: { + all: ['widget', 'changelog'] as const, + byId: (entryId: string, version: number) => ['widget', 'changelog', entryId, version] as const, + }, + changelogList: { + all: ['widget', 'changelogs'] as const, + bySession: (version: number) => ['widget', 'changelogs', version] as const, + }, + popularPosts: { + list: (boardSlug: string | null, version: number) => + ['widget', 'posts', 'popular', 'top', boardSlug ?? 'all', version] as const, + }, + popularSearch: { + query: (q: string, boardSlug: string | null, version: number) => + ['widget', 'search', 'popular', q, boardSlug ?? 'all', version] as const, + }, + helpCategories: { + bySession: (version: number, locale: string) => + ['widget', 'help', 'categories', locale, version] as const, + }, + helpCategoryArticles: { + byCategory: (categoryId: string, version: number, locale: string) => + ['widget', 'help', 'category-articles', categoryId, locale, version] as const, + }, +} + +/** True when the last key slot is this session (popular search dim-hold). */ +export function widgetQueryKeySameSession( + actual: readonly unknown[] | undefined, + sessionVersion: number +): boolean { + return !!actual && actual[actual.length - 1] === sessionVersion +} + +/** True when `actual` is the same factory key (avoids placeholder index coupling). */ +export function widgetQueryKeyEquals( + expected: readonly unknown[], + actual: readonly unknown[] | undefined +): boolean { + return ( + !!actual && actual.length === expected.length && widgetQueryKeyPrefixEquals(expected, actual) + ) +} + +/** True when `actual` starts with `prefix` — same entity, any trailing key slots. */ +export function widgetQueryKeyPrefixEquals( + prefix: readonly unknown[], + actual: readonly unknown[] | undefined +): boolean { + return !!actual && prefix.every((part, i) => actual[i] === part) } interface UseWidgetVoteOptions { diff --git a/apps/web/src/lib/client/utils/agui-fetch.ts b/apps/web/src/lib/client/utils/agui-fetch.ts index ce9feb0f93..5a177573f8 100644 --- a/apps/web/src/lib/client/utils/agui-fetch.ts +++ b/apps/web/src/lib/client/utils/agui-fetch.ts @@ -16,9 +16,16 @@ function aguiErrorFrame(code: string, message: string): string { return `data: ${JSON.stringify({ type: 'RUN_ERROR', code, message })}\n\n` } -export function aguiFetchClient(): typeof fetch { +export function aguiFetchClient(getHeaders?: () => HeadersInit | undefined): typeof fetch { const wrapped = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const res = await fetch(input, init) + const extra = getHeaders?.() + const headers = extra ? new Headers(init?.headers) : init?.headers + if (extra) { + new Headers(extra).forEach((value, key) => { + ;(headers as Headers).set(key, value) + }) + } + const res = await fetch(input, extra ? { ...init, headers } : init) if (res.ok) return res const message = await extractHttpErrorMessage(res) return new Response(aguiErrorFrame(`http_${res.status}`, message), { diff --git a/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts b/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts index 3f55368ea6..24bb4096a3 100644 --- a/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/portal-gate-extended.test.ts @@ -373,27 +373,31 @@ describe('portal.ts fetchBoardCapabilitiesFn — per-board capability map', () = mockResolvePortalAccess.mockResolvedValue({ granted: false, reason: 'unauthorized' }) const handler = await loadExportedHandler(PORTAL, 'fetchBoardCapabilitiesFn') const result = await handler({ data: {} }) - expect(result).toEqual({}) + expect(result).toEqual({ permissions: {}, boards: [] }) expect(mockListPublicBoardsWithStats).not.toHaveBeenCalled() }) it('maps each visible board to its submit/vote capability for the actor', async () => { mockResolvePortalAccess.mockResolvedValue({ granted: true, reason: 'public' }) mockListPublicBoardsWithStats.mockResolvedValue([ - { id: 'board_pub', access: anonAccess }, - { id: 'board_auth', access: authAccess }, + { id: 'board_pub', name: 'Public', slug: 'public', access: anonAccess }, + { id: 'board_auth', name: 'Auth', slug: 'auth', access: authAccess }, ]) const handler = await loadExportedHandler(PORTAL, 'fetchBoardCapabilitiesFn') - const result = (await handler({ data: {} })) as Record< - string, - { canSubmit: boolean; canVote: boolean } - > + const result = (await handler({ data: {} })) as { + permissions: Record + boards: { id: string; name: string; slug: string }[] + } // Anonymous actor (mocked) + workspace allowAnonymous=true: the all-anonymous // board is actionable, the sign-in-required board is not. - expect(result).toEqual({ + expect(result.permissions).toEqual({ board_pub: { canSubmit: true, canVote: true }, board_auth: { canSubmit: false, canVote: false }, }) + expect(result.boards).toEqual([ + { id: 'board_pub', name: 'Public', slug: 'public' }, + { id: 'board_auth', name: 'Auth', slug: 'auth' }, + ]) }) }) diff --git a/apps/web/src/lib/server/functions/help-center.ts b/apps/web/src/lib/server/functions/help-center.ts index 1093b309c7..ce4f0e1e7e 100644 --- a/apps/web/src/lib/server/functions/help-center.ts +++ b/apps/web/src/lib/server/functions/help-center.ts @@ -583,3 +583,40 @@ export const searchPublicArticlesFn = createServerFn({ method: 'GET' }) ) return hybridSearchForLocale(data.query, locale, data.limit ?? 10, await publicViewer()) }) + +/** + * Public article by widget `open({ articleId })` ref — same shape as + * getPublicArticleBySlugFn. `article_` and `kb_article_` TypeIDs look up by + * id (same UUID); anything else is a slug. Missing or gated → null. + * Appended so existing help-center handler indices stay put. + */ +export const resolvePublicArticleRefFn = createServerFn({ method: 'GET' }) + .validator(z.object({ ref: z.string().min(1), locale: z.string().optional() })) + .handler(async ({ data }) => { + const { articleTypeIdToKbArticleId } = await import('@/lib/shared/widget/article-ref') + const { getPublicArticleByIdForLocale, getPublicArticleBySlugForLocale } = + await import('@/lib/server/domains/help-center/help-center-locale.query') + const { DEFAULT_LOCALE } = await import('@/lib/shared/i18n') + const { NotFoundError } = await import('@/lib/shared/errors') + const { withDefaultLocaleFallback } = await import('@/lib/shared/widget/article-locale') + const viewer = await publicViewer() + const locale = data.locale ?? DEFAULT_LOCALE + try { + const kbId = articleTypeIdToKbArticleId(data.ref) + const load = (loc: string) => + kbId + ? getPublicArticleByIdForLocale(kbId, loc, viewer) + : getPublicArticleBySlugForLocale(data.ref, loc, viewer) + const { value: article, locale: resolvedLocale } = await withDefaultLocaleFallback( + locale, + DEFAULT_LOCALE, + load, + (err) => err instanceof NotFoundError + ) + const { helpfulCount: _h, notHelpfulCount: _n, ...publicArticle } = serializeArticle(article) + return { ...publicArticle, resolvedLocale } + } catch (err) { + if (err instanceof NotFoundError) return null + throw err + } + }) diff --git a/apps/web/src/lib/server/functions/portal.ts b/apps/web/src/lib/server/functions/portal.ts index ee7fb7d181..b6529ce3e6 100644 --- a/apps/web/src/lib/server/functions/portal.ts +++ b/apps/web/src/lib/server/functions/portal.ts @@ -745,7 +745,7 @@ export const fetchBoardCapabilitiesFn = createServerFn({ method: 'GET' }).handle // Same portal-visibility + per-board gates as fetchPortalData. const access = await resolvePortalAccessForRequest() - if (!access.granted) return empty + if (!access.granted) return { permissions: empty, boards: [] as WidgetVisibleBoard[] } const auth = await getOptionalAuth() const actor = await policyActorFromAuth(auth) @@ -756,5 +756,16 @@ export const fetchBoardCapabilitiesFn = createServerFn({ method: 'GET' }).handle listPublicBoardsWithStats(actor), loadAllowAnonymous(), ]) - return buildBoardPermissions(actor, boards, allowAnonymous) + return { + permissions: await buildBoardPermissions(actor, boards, allowAnonymous), + // Same visitor-visible list as the permissions map, so identify can + // surface segment/members boards the anonymous SSR seed omitted. + boards: boards.map((board): WidgetVisibleBoard => ({ + id: String(board.id), + name: board.name, + slug: board.slug, + })), + } }) + +export type WidgetVisibleBoard = { id: string; name: string; slug: string } diff --git a/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md b/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md index 38a3aca1f9..b1e3e6d1ef 100644 --- a/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md +++ b/apps/web/src/lib/server/policy/authz-matrix/MATRIX.md @@ -993,7 +993,7 @@ Key scopes are enforced: an API key holds exactly its stored scopes (owner permi ## 4. Entry points without a requireAuth/key gate -193 of 987 entry points hold no `requireAuth` / `withApiKeyAuth` / `requireTeamAuth` gate. +194 of 988 entry points hold no `requireAuth` / `withApiKeyAuth` / `requireTeamAuth` gate. Each is expected to be intentionally public, a pre-auth flow, a signature-verified webhook, or a handler that delegates auth (e.g. the MCP route). **Adding a row here is an access-control change** — confirm the new entry point is meant to be reachable without a gate. @@ -1034,6 +1034,7 @@ Each is expected to be intentionally public, a pre-auth flow, a signature-verifi | `lib/server/functions/help-center.ts`::listPublicCategoriesFn | server-fn | | `lib/server/functions/help-center.ts`::listPublicCategoryEditorsFn | server-fn | | `lib/server/functions/help-center.ts`::recordArticleFeedbackFn | server-fn | +| `lib/server/functions/help-center.ts`::resolvePublicArticleRefFn | server-fn | | `lib/server/functions/help-center.ts`::searchPublicArticlesFn | server-fn | | `lib/server/functions/help-center.ts`::submitArticleFeedbackReasonFn | server-fn | | `lib/server/functions/instant-sso.ts`::resolveInstantSsoRedirectFn | server-fn | diff --git a/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts b/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts new file mode 100644 index 0000000000..2e2326cb46 --- /dev/null +++ b/apps/web/src/lib/shared/widget/__tests__/article-locale.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest' +import { NotFoundError } from '@/lib/shared/errors' +import { withDefaultLocaleFallback } from '../article-locale' + +describe('withDefaultLocaleFallback', () => { + it('returns the requested locale when present', async () => { + const load = vi.fn(async (locale: string) => locale) + await expect(withDefaultLocaleFallback('de', 'en', load, () => false)).resolves.toEqual({ + value: 'de', + locale: 'de', + }) + expect(load).toHaveBeenCalledTimes(1) + }) + + it('falls back to the default locale when the requested translation is missing', async () => { + const load = vi.fn(async (locale: string) => { + if (locale === 'de') throw new NotFoundError('ARTICLE_NOT_FOUND', 'missing') + return 'en-article' + }) + await expect( + withDefaultLocaleFallback('de', 'en', load, (err) => err instanceof NotFoundError) + ).resolves.toEqual({ value: 'en-article', locale: 'en' }) + expect(load).toHaveBeenCalledWith('de') + expect(load).toHaveBeenCalledWith('en') + }) + + it('does not retry when already on the default locale', async () => { + const err = new NotFoundError('ARTICLE_NOT_FOUND', 'missing') + const load = vi.fn(async () => { + throw err + }) + await expect( + withDefaultLocaleFallback('en', 'en', load, (e) => e instanceof NotFoundError) + ).rejects.toBe(err) + expect(load).toHaveBeenCalledTimes(1) + }) + + it('rethrows errors that are not a missing translation', async () => { + const err = new Error('db down') + const load = vi.fn(async () => { + throw err + }) + await expect( + withDefaultLocaleFallback('de', 'en', load, (e) => e instanceof NotFoundError) + ).rejects.toBe(err) + expect(load).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts b/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts new file mode 100644 index 0000000000..4670413956 --- /dev/null +++ b/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' +import { generateId } from '@quackback/ids' +import { articleTypeIdToKbArticleId, isArticleTypeId } from '../article-ref' + +describe('article TypeID refs', () => { + it('treats article_ and kb_article_ as the same row', () => { + const stored = generateId('kb_article') + const published = `article_${stored.slice('kb_article_'.length)}` + expect(isArticleTypeId(stored)).toBe(true) + expect(isArticleTypeId(published)).toBe(true) + expect(articleTypeIdToKbArticleId(published)).toBe(stored) + expect(articleTypeIdToKbArticleId(stored)).toBe(stored) + }) + + it('rejects slugs and the old art_ prefix', () => { + expect(isArticleTypeId('pricing')).toBe(false) + expect(isArticleTypeId('art_01h...')).toBe(false) + expect(articleTypeIdToKbArticleId('pricing')).toBeNull() + }) +}) diff --git a/apps/web/src/lib/shared/widget/article-locale.ts b/apps/web/src/lib/shared/widget/article-locale.ts new file mode 100644 index 0000000000..ad2c7ee593 --- /dev/null +++ b/apps/web/src/lib/shared/widget/article-locale.ts @@ -0,0 +1,20 @@ +/** + * Widget `open({ articleId })` should show the requested locale when a + * translation exists, and the default-locale article otherwise — same + * fallback as help search. Portal `/hc/{locale}/…` URLs stay strict 404. + */ +export async function withDefaultLocaleFallback( + locale: string, + defaultLocale: string, + load: (locale: string) => Promise, + isMissing: (err: unknown) => boolean +): Promise<{ value: T; locale: string }> { + try { + return { value: await load(locale), locale } + } catch (err) { + if (isMissing(err) && locale !== defaultLocale) { + return { value: await load(defaultLocale), locale: defaultLocale } + } + throw err + } +} diff --git a/apps/web/src/lib/shared/widget/article-ref.ts b/apps/web/src/lib/shared/widget/article-ref.ts new file mode 100644 index 0000000000..dd205cbaf5 --- /dev/null +++ b/apps/web/src/lib/shared/widget/article-ref.ts @@ -0,0 +1,31 @@ +import { fromUuid, isValidTypeId, parseTypeId, type KbArticleId } from '@quackback/ids' + +/** Public `article_` plus the stored help-center prefix. */ +const ARTICLE_TYPEID_PREFIXES = new Set(['article', 'kb_article']) + +/** + * True for an article TypeID (`article_…` or `kb_article_…`). + * Slugs and the old undocumented `art_` prefix are not. + */ +export function isArticleTypeId(ref: string): boolean { + if (!isValidTypeId(ref)) return false + try { + return ARTICLE_TYPEID_PREFIXES.has(parseTypeId(ref).prefix) + } catch { + return false + } +} + +/** + * Map an `article_` / `kb_article_` TypeID onto the stored `KbArticleId`. + * Same UUID, canonical prefix — so a public `article_` id looks up the row. + */ +export function articleTypeIdToKbArticleId(ref: string): KbArticleId | null { + try { + const { prefix, uuid } = parseTypeId(ref) + if (!ARTICLE_TYPEID_PREFIXES.has(prefix)) return null + return fromUuid('kb_article', uuid) + } catch { + return null + } +} diff --git a/apps/web/src/lib/shared/widget/types.ts b/apps/web/src/lib/shared/widget/types.ts index 1b6370402f..06239e7078 100644 --- a/apps/web/src/lib/shared/widget/types.ts +++ b/apps/web/src/lib/shared/widget/types.ts @@ -43,9 +43,14 @@ export interface WidgetInboundMessages { 'quackback:locale': string 'quackback:open': | { - view?: 'home' | 'new-post' + view?: 'home' | 'new-post' | 'changelog' | 'help' | 'chat' | 'messages' | 'tickets' title?: string + body?: string board?: string + query?: string + entryId?: string + postId?: string + articleId?: string // `article_…` TypeID or public slug } | undefined } diff --git a/apps/web/src/routes/widget/index.tsx b/apps/web/src/routes/widget/index.tsx index 420f542ce1..8b5fb3c5d6 100644 --- a/apps/web/src/routes/widget/index.tsx +++ b/apps/web/src/routes/widget/index.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { useQuery, keepPreviousData } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { z } from 'zod' import { lazy, @@ -23,25 +23,26 @@ import { type WidgetView, resolveInitialTab, resolveInitialView, - homeEnabled, contentSurfaceCount, isExpandedView, visibleTabsForVisitor, } from '@/components/widget/widget-nav' +import { resolveOpenCommand, type WidgetComposeRequest } from '@/components/widget/widget-compose' import { WidgetHome } from '@/components/widget/widget-home' import { WidgetOverview } from '@/components/widget/widget-overview' import { WidgetHeroBackdrop } from '@/components/widget/widget-hero-backdrop' import type { ConversationId } from '@quackback/ids' import { useWidgetAuth } from '@/components/widget/widget-auth-provider' import { portalQueries } from '@/lib/client/queries/portal' -import { publicChangelogQueries } from '@/lib/client/queries/changelog' -import { publicHelpCenterQueries } from '@/lib/client/queries/help-center' +import { widgetChangelogListQuery } from '@/components/widget/widget-changelog-query' +import { widgetHelpCategoriesQuery } from '@/components/widget/widget-help-query' import { fetchBoardCapabilitiesFn } from '@/lib/server/functions/portal' import { getShowPoweredByFn } from '@/lib/server/functions/powered-by' import { listPublicArticlesFn } from '@/lib/server/functions/help-center' import { getWidgetAuthHeaders } from '@/lib/client/widget-auth' import { sendToHost } from '@/lib/client/widget-bridge' import { widgetQueryKeys, INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote' +import { DEFAULT_LOCALE } from '@/lib/shared/i18n' import { CONVERSATION_PRESENCE_QUERY_KEY, useConversationPresence, @@ -189,10 +190,14 @@ export const Route = createFileRoute('/widget/')({ .catch(() => {}) : Promise.resolve(), changelogTabEnabled - ? queryClient.ensureInfiniteQueryData(publicChangelogQueries.list()).catch(() => {}) + ? queryClient + .ensureInfiniteQueryData(widgetChangelogListQuery(INITIAL_SESSION_VERSION)) + .catch(() => {}) : Promise.resolve(), helpTabEnabled - ? queryClient.ensureQueryData(publicHelpCenterQueries.categories()).catch(() => {}) + ? queryClient + .ensureQueryData(widgetHelpCategoriesQuery(INITIAL_SESSION_VERSION, DEFAULT_LOCALE)) + .catch(() => {}) : Promise.resolve(), helpTabEnabled ? listPublicArticlesFn({ data: { limit: 4 } }) @@ -398,6 +403,7 @@ function WidgetPage() { messengerEnabled, showPoweredBy, } = Route.useLoaderData() + const { board: initialBoardSlug } = Route.useSearch() const { ensureSession, sessionVersion } = useWidgetAuth() const intl = useIntl() @@ -407,20 +413,26 @@ function WidgetPage() { // feed gates votes/submission per the actual actor instead of OR-ing in a // blanket isIdentified (which advertised CTAs on segments/team boards the // actor cannot act on). Seeded with the loader map so SSR + first paint match. - const { data: livePermissions } = useQuery({ - queryKey: ['widget', 'boardPermissions', sessionVersion], + const { data: liveCapabilities } = useQuery({ + queryKey: ['widget', 'boardCapabilities', sessionVersion], queryFn: () => fetchBoardCapabilitiesFn({ headers: getWidgetAuthHeaders() }), // Seed ONLY the initial (anonymous, SSR) key from the loader. initialData // stamps an entry fresh as of now, so seeding it on every key would also // mark the post-identify key fresh and suppress the Bearer refetch within // staleTime — leaving an identified viewer stuck on the anonymous baseline. - // After identify the key changes, carries no initialData, and refetches with - // the Bearer while keepPreviousData shows the prior map meanwhile. - initialData: sessionVersion === INITIAL_SESSION_VERSION ? boardPermissions : undefined, - placeholderData: keepPreviousData, + // After identify the key changes, carries no initialData, and refetches + // with the Bearer. Do not keepPreviousData — logout/switch would otherwise + // show the prior visitor's members-only boards until the new fetch lands. + // Missing data falls back to the anonymous SSR `boards` list. + initialData: + sessionVersion === INITIAL_SESSION_VERSION + ? { permissions: boardPermissions, boards } + : undefined, staleTime: 30 * 1000, enabled: !!tabs.feedback, }) + const livePermissions = liveCapabilities?.permissions + const liveBoards = liveCapabilities?.boards ?? boards const { c: resumeConversationId } = Route.useSearch() const { hasTickets } = useTicketStageBadge(!!tabs.tickets) @@ -504,6 +516,8 @@ function WidgetPage() { name: string icon: string | null } | null>(null) + const [composeRequest, setComposeRequest] = useState(null) + const composeNonceRef = useRef(0) const [createdPosts, setCreatedPosts] = useState([]) const allPosts = useMemo(() => { @@ -570,41 +584,84 @@ function WidgetPage() { setHostIsMobile(!!msg.data) return } - if (msg.type !== 'quackback:open' || !msg.data) return + if (msg.type !== 'quackback:open') return + + const opts = (msg.data ?? {}) as { + view?: string + title?: string + body?: string + board?: string + query?: string + entryId?: string + postId?: string + articleId?: string + } + const command = resolveOpenCommand(opts, tabs) + if (!command) return - const opts = msg.data as { view?: string } // SDK-driven opens are tab-level landings: no back-chevron origin. setBackTarget(null) lastNavRef.current = 'tab' - if (opts.view === 'changelog' && tabs.changelog) { - setActiveTab('changelog') - setView('changelog') - } else if (opts.view === 'help' && tabs.help) { - // Same fresh start as navigateToTab('help'): the lifted search - // would otherwise resurface an old query on a programmatic open. - setSelectedHelpSlug(null) - setSelectedCategory(null) - setHelpSearch('') - setActiveTab('help') - setView('help') - } else if ( - (opts.view === 'messages' || opts.view === 'chat' || opts.view === 'live-chat') && - tabs.messages - ) { - openMessenger() - } else if (opts.view === 'tickets') { - // The requester's own-tickets list lives on the Tickets tab; on - // workspaces without it, ticket threads are listed in Messages. - if (tabs.tickets) { + switch (command.type) { + case 'new-post': + composeNonceRef.current += 1 + setComposeRequest({ + nonce: composeNonceRef.current, + title: command.title, + body: command.body, + boardSlug: command.boardSlug, + }) + setSelectedPostId(null) + setActiveTab('feedback') + setView('feedback') + break + case 'post': + setActiveTab('feedback') + setSelectedPostId(command.postId) + setView('post-detail') + break + case 'article': + // Same as postId: store the ref and let the detail view fetch it + // with Bearer + sessionVersion. TypeIDs (`article_` / `kb_article_`) + // and slugs both resolve server-side; no client hop. + setSelectedCategory(null) + setHelpSearch('') + setSelectedHelpSlug(command.articleId) + setActiveTab('help') + setView('help-detail') + break + case 'changelog': + setActiveTab('changelog') + if (command.entryId) { + setSelectedChangelogId(command.entryId) + setView('changelog-detail') + } else { + setSelectedChangelogId(null) + setView('changelog') + } + break + case 'help': + setSelectedHelpSlug(null) + setSelectedCategory(null) + setHelpSearch(command.query ?? '') + setActiveTab('help') + setView('help') + break + case 'messenger': + openMessenger() + break + case 'tickets': setActiveTab('tickets') setView('tickets') - } else if (tabs.messages) { + break + case 'messages': setActiveTab('messages') setView('messages') - } - } else if ((opts.view === 'home' || opts.view === 'overview') && homeEnabled(tabs)) { - setActiveTab('home') - setView('overview') + break + case 'home': + setActiveTab('home') + setView('overview') + break } } window.addEventListener('message', handleMessage) @@ -762,6 +819,12 @@ function WidgetPage() { setView('help-detail') }, []) + const handleHelpCategoryUnavailable = useCallback(() => { + lastNavRef.current = 'move' + setSelectedCategory(null) + setView('help') + }, []) + // The feedback view stays mounted (form state survives a detail round-trip), // so it can't take focus via ViewTransition's mount hook; do it when it // becomes visible again after a back/cross navigation. @@ -992,6 +1055,7 @@ function WidgetPage() { categoryName={selectedCategory.name} categoryIcon={selectedCategory.icon} onArticleSelect={handleHelpCategoryArticleSelect} + onCategoryUnavailable={handleHelpCategoryUnavailable} /> )} @@ -1004,7 +1068,7 @@ function WidgetPage() { fallback={} > handleHelpCategorySelect(id, name, null)} onAskQuestion={ messengerEnabled @@ -1043,9 +1107,12 @@ function WidgetPage() { initialPosts={allPosts} initialHasMore={postsHasMore} statuses={statuses} - boards={boards} + boards={liveBoards} boardPermissions={livePermissions} defaultBoard={defaultBoard} + initialBoardSlug={initialBoardSlug} + confirmedBoardSlugs={liveCapabilities?.boards.map((b) => b.slug) ?? null} + composeRequest={composeRequest} onPostSelect={handlePostSelect} onPostCreated={handlePostCreated} /> diff --git a/apps/web/src/test/agui.ts b/apps/web/src/test/agui.ts index c9dc84900e..1a644a625c 100644 --- a/apps/web/src/test/agui.ts +++ b/apps/web/src/test/agui.ts @@ -77,7 +77,9 @@ export function structuredDeltas(object: unknown, pieces = 3): Chunk[] { * Returns the mock for request-body assertions; undo via * `vi.unstubAllGlobals()`. */ export function stubAguiFetch(frames: string) { - const fetchMock = vi.fn(() => Promise.resolve(mockStreamingResponse(frames))) + const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit): Promise => + Promise.resolve(mockStreamingResponse(frames)) + ) vi.stubGlobal('fetch', fetchMock) return fetchMock } diff --git a/packages/widget/README.md b/packages/widget/README.md index c80b686be2..eb48a0d46d 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -113,14 +113,16 @@ See the [Identify users guide](https://quackback.io/docs/widget/identify-users) ```ts Quackback.open() // home -Quackback.open({ view: 'new-post', title: 'Bug:', body: '...' }) // pre-filled form +Quackback.open({ view: 'new-post', title: 'Bug:', body: '...', board: 'bugs' }) // pre-filled form Quackback.open({ view: 'changelog' }) // changelog feed Quackback.open({ view: 'help', query: 'pricing' }) // help search Quackback.open({ postId: 'post_01h...' }) // specific post -Quackback.open({ articleId: 'art_01h...' }) // help article +Quackback.open({ articleId: 'article_01h...' }) // help article TypeID or slug ``` -`view`, `title`, and `board` are live. `body`, `query`, `postId`, `articleId`, `entryId` pass through today and render in a follow-up release. +`postId` and `articleId` win over `view` if both are set. `board` on `open` only applies to `view: 'new-post'`. Home / Popular Ideas filtering uses `init({ defaultBoard })` or the iframe `?board=` param. + +Every `open` field is live. A disabled surface or an unseen board fails closed — the panel still opens, but the widget does not invent access. ### Events diff --git a/packages/widget/__tests__/sdk.test.ts b/packages/widget/__tests__/sdk.test.ts index acaee3d7f0..ae3feec08f 100644 --- a/packages/widget/__tests__/sdk.test.ts +++ b/packages/widget/__tests__/sdk.test.ts @@ -199,6 +199,19 @@ describe('sdk', () => { { type: 'quackback:open', data: { postId: 'post_01h' } }, ORIGIN ) + sdk.dispatch('open', { + view: 'new-post', + title: 'Bug:', + body: 'steps', + board: 'bug-reports', + }) + expect(postMessage).toHaveBeenLastCalledWith( + { + type: 'quackback:open', + data: { view: 'new-post', title: 'Bug:', body: 'steps', board: 'bug-reports' }, + }, + ORIGIN + ) spy.mockRestore() }) diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index e21f4b3c96..d08b400c7f 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -53,21 +53,25 @@ export type Identity = /** * Arguments to `Quackback.open(...)`. Discriminated on the target: - * - omit the payload to open the home view + * - omit the payload or `{ view: 'home' }` to open the home view * - `{ view: 'new-post', title?, body?, board? }` pre-fills the new-post form * - `{ view: 'changelog', entryId? }` opens the changelog, optionally to one entry * - `{ view: 'help', query? }` opens help, optionally with search prefilled * - `{ view: 'chat' }` opens the live chat view * - `{ postId }` deep-links to a specific post - * - `{ articleId }` deep-links to a help article + * - `{ articleId }` deep-links to a help article (`article_…` TypeID or slug; + * stored `kb_article_…` ids also resolve) * - * Fields `view` / `title` / `board` are handled by the iframe today. - * `body`, `query`, `postId`, `articleId`, `entryId` pass through the postMessage - * protocol; full iframe-side handling lands in follow-up iframe work. + * `postId` and `articleId` win over `view` when both are set. `board` applies + * only to `new-post` — Home filtering uses `init({ defaultBoard })` or `?board=`. + * + * The iframe handles every field on this type. A target whose surface is + * disabled (or a board the visitor cannot see) fails closed — the panel + * still opens, but the widget does not invent access. */ export type OpenOptions = | undefined - | { view?: 'home'; board?: string } + | { view?: 'home' } | { view: 'new-post'; title?: string; body?: string; board?: string } | { view: 'changelog'; entryId?: string } | { view: 'help'; query?: string }