From 623b7dc63d6772c6ee06cba19d9fdce3030c3560 Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 8 Apr 2026 13:21:56 +0000 Subject: [PATCH 1/3] feat(companion): add browsing context consent prompt Add one-time consent prompt in the companion widget asking whether browsing context can be used for personalized content recommendations. - Add browsingContextEnabled to frontend SettingsFlags type and enum - Add companion_browsing_consent GrowthBook feature flag - Add analytics events: show, accept, decline - Create useCompanionBrowsingConsent hook following useNoAiFeed pattern - Integrate hook into Companion component - Opt-in only: absence of flag = no consent, decline persists dismiss state via flags.prompt without writing false - GrowthBook gated for A/B testing before full rollout Co-Authored-By: Claude Opus 4.6 --- .../extension/src/companion/Companion.tsx | 2 + packages/shared/src/graphql/settings.ts | 2 + .../hooks/useCompanionBrowsingConsent.spec.ts | 182 ++++++++++++++++++ .../src/hooks/useCompanionBrowsingConsent.ts | 80 ++++++++ packages/shared/src/lib/featureManagement.ts | 4 + packages/shared/src/lib/log.ts | 5 + 6 files changed, 275 insertions(+) create mode 100644 packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts create mode 100644 packages/shared/src/hooks/useCompanionBrowsingConsent.ts diff --git a/packages/extension/src/companion/Companion.tsx b/packages/extension/src/companion/Companion.tsx index a975a74d443..37fb1e1b242 100644 --- a/packages/extension/src/companion/Companion.tsx +++ b/packages/extension/src/companion/Companion.tsx @@ -12,6 +12,7 @@ import type { } from '@dailydotdev/shared/src/lib/boot'; import useLogPageView from '@dailydotdev/shared/src/hooks/log/useLogPageView'; import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; +import { useCompanionBrowsingConsent } from '@dailydotdev/shared/src/hooks/useCompanionBrowsingConsent'; import { usePopupSelector } from '@dailydotdev/shared/src/hooks/usePopupSelector'; import { useBackgroundRequest } from '@dailydotdev/shared/src/hooks/companion'; import { @@ -85,6 +86,7 @@ export default function Companion({ useBackgroundRequest(refreshTokenKey, { callback: ({ res }) => onUpdateToken(res.accessToken), }); + useCompanionBrowsingConsent(); const containerRef = useRef(); const [assetsLoaded, setAssetsLoaded] = useState(isTesting); usePopupSelector({ parentSelector: getCompanionWrapper }); diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 5fead6dc4e2..eed0d5e771b 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -18,6 +18,7 @@ export type SettingsFlags = { sidebarBookmarksExpanded: boolean; clickbaitShieldEnabled: boolean; noAiFeedEnabled?: boolean; + browsingContextEnabled?: boolean; timezoneMismatchIgnore?: string; prompt?: Record; lastPrompt?: string; @@ -32,6 +33,7 @@ export enum SidebarSettingsFlags { BookmarksExpanded = 'sidebarBookmarksExpanded', ClickbaitShieldEnabled = 'clickbaitShieldEnabled', NoAiFeedEnabled = 'noAiFeedEnabled', + BrowsingContextEnabled = 'browsingContextEnabled', } export type RemoteSettings = { diff --git a/packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts b/packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts new file mode 100644 index 00000000000..c77479e2679 --- /dev/null +++ b/packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts @@ -0,0 +1,182 @@ +import { renderHook, act } from '@testing-library/react'; +import { useCompanionBrowsingConsent } from './useCompanionBrowsingConsent'; +import { useConditionalFeature } from './useConditionalFeature'; +import { useSettingsContext } from '../contexts/SettingsContext'; +import { usePrompt } from './usePrompt'; +import { useLogContext } from '../contexts/LogContext'; +import { LogEvent, Origin } from '../lib/log'; +import { SidebarSettingsFlags } from '../graphql/settings'; + +jest.mock('./useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +jest.mock('../contexts/SettingsContext', () => ({ + useSettingsContext: jest.fn(), +})); + +jest.mock('./usePrompt', () => ({ + usePrompt: jest.fn(), +})); + +jest.mock('../contexts/LogContext', () => ({ + useLogContext: jest.fn(), +})); + +const mockUseConditionalFeature = useConditionalFeature as jest.Mock; +const mockUseSettingsContext = useSettingsContext as jest.Mock; +const mockUsePrompt = usePrompt as jest.Mock; +const mockUseLogContext = useLogContext as jest.Mock; + +describe('useCompanionBrowsingConsent', () => { + const updateFlag = jest.fn().mockResolvedValue(undefined); + const updatePromptFlag = jest.fn().mockResolvedValue(undefined); + const showPrompt = jest.fn(); + const logEvent = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseConditionalFeature.mockReturnValue({ + value: true, + isLoading: false, + }); + mockUseSettingsContext.mockReturnValue({ + flags: { + browsingContextEnabled: false, + prompt: {}, + }, + loadedSettings: true, + updateFlag, + updatePromptFlag, + }); + mockUsePrompt.mockReturnValue({ showPrompt }); + mockUseLogContext.mockReturnValue({ logEvent }); + }); + + it('shows the consent prompt when feature is enabled and user has not seen it', async () => { + showPrompt.mockResolvedValue(true); + + await act(async () => { + renderHook(() => useCompanionBrowsingConsent()); + }); + + expect(showPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Personalize your feed with browsing context?', + okButton: { title: 'Allow' }, + cancelButton: { title: 'No thanks' }, + }), + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.BrowsingConsentPromptShow, + extra: JSON.stringify({ origin: Origin.Companion }), + }); + }); + + it('saves browsingContextEnabled flag when user accepts', async () => { + showPrompt.mockResolvedValue(true); + + await act(async () => { + renderHook(() => useCompanionBrowsingConsent()); + }); + + expect(updateFlag).toHaveBeenCalledWith( + SidebarSettingsFlags.BrowsingContextEnabled, + true, + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.BrowsingConsentPromptAccept, + extra: JSON.stringify({ origin: Origin.Companion }), + }); + expect(updatePromptFlag).toHaveBeenCalledWith( + 'browsing_context_consent_prompt', + true, + ); + }); + + it('does not save browsingContextEnabled when user declines', async () => { + showPrompt.mockResolvedValue(false); + + await act(async () => { + renderHook(() => useCompanionBrowsingConsent()); + }); + + expect(updateFlag).not.toHaveBeenCalled(); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.BrowsingConsentPromptDecline, + extra: JSON.stringify({ origin: Origin.Companion }), + }); + expect(updatePromptFlag).toHaveBeenCalledWith( + 'browsing_context_consent_prompt', + true, + ); + }); + + it('does not show prompt when feature is disabled', async () => { + mockUseConditionalFeature.mockReturnValue({ + value: false, + isLoading: false, + }); + + await act(async () => { + renderHook(() => useCompanionBrowsingConsent()); + }); + + expect(showPrompt).not.toHaveBeenCalled(); + }); + + it('does not show prompt when user already consented', async () => { + mockUseSettingsContext.mockReturnValue({ + flags: { + browsingContextEnabled: true, + prompt: {}, + }, + loadedSettings: true, + updateFlag, + updatePromptFlag, + }); + + await act(async () => { + renderHook(() => useCompanionBrowsingConsent()); + }); + + expect(showPrompt).not.toHaveBeenCalled(); + }); + + it('does not show prompt when user already dismissed it', async () => { + mockUseSettingsContext.mockReturnValue({ + flags: { + browsingContextEnabled: false, + prompt: { browsing_context_consent_prompt: true }, + }, + loadedSettings: true, + updateFlag, + updatePromptFlag, + }); + + await act(async () => { + renderHook(() => useCompanionBrowsingConsent()); + }); + + expect(showPrompt).not.toHaveBeenCalled(); + }); + + it('does not show prompt when settings are not loaded', async () => { + mockUseSettingsContext.mockReturnValue({ + flags: { + browsingContextEnabled: false, + prompt: {}, + }, + loadedSettings: false, + updateFlag, + updatePromptFlag, + }); + + await act(async () => { + renderHook(() => useCompanionBrowsingConsent()); + }); + + expect(showPrompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/hooks/useCompanionBrowsingConsent.ts b/packages/shared/src/hooks/useCompanionBrowsingConsent.ts new file mode 100644 index 00000000000..82958bd13b9 --- /dev/null +++ b/packages/shared/src/hooks/useCompanionBrowsingConsent.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useSettingsContext } from '../contexts/SettingsContext'; +import { useConditionalFeature } from './useConditionalFeature'; +import { featureCompanionBrowsingConsent } from '../lib/featureManagement'; +import { usePrompt } from './usePrompt'; +import { useLogContext } from '../contexts/LogContext'; +import { LogEvent, Origin } from '../lib/log'; +import { SidebarSettingsFlags } from '../graphql/settings'; + +const BROWSING_CONSENT_PROMPT_FLAG = 'browsing_context_consent_prompt'; + +export const useCompanionBrowsingConsent = (): void => { + const { value: featureEnabled } = useConditionalFeature({ + feature: featureCompanionBrowsingConsent, + shouldEvaluate: true, + }); + const { flags, loadedSettings, updateFlag, updatePromptFlag } = + useSettingsContext(); + const { showPrompt } = usePrompt(); + const { logEvent } = useLogContext(); + const promptShownRef = useRef(false); + + const hasConsented = !!flags?.browsingContextEnabled; + const hasDismissedPrompt = !!flags?.prompt?.[BROWSING_CONSENT_PROMPT_FLAG]; + + const showConsentPrompt = useCallback(async () => { + if (promptShownRef.current) { + return; + } + + promptShownRef.current = true; + + logEvent({ + event_name: LogEvent.BrowsingConsentPromptShow, + extra: JSON.stringify({ origin: Origin.Companion }), + }); + + const accepted = await showPrompt({ + title: 'Personalize your feed with browsing context?', + description: + 'Allow daily.dev to use your browsing context to recommend more relevant content. You can change this anytime in settings.', + okButton: { title: 'Allow' }, + cancelButton: { title: 'No thanks' }, + }); + + if (accepted) { + await updateFlag(SidebarSettingsFlags.BrowsingContextEnabled, true); + logEvent({ + event_name: LogEvent.BrowsingConsentPromptAccept, + extra: JSON.stringify({ origin: Origin.Companion }), + }); + } else { + logEvent({ + event_name: LogEvent.BrowsingConsentPromptDecline, + extra: JSON.stringify({ origin: Origin.Companion }), + }); + } + + await updatePromptFlag(BROWSING_CONSENT_PROMPT_FLAG, true); + }, [logEvent, showPrompt, updateFlag, updatePromptFlag]); + + useEffect(() => { + if ( + !featureEnabled || + !loadedSettings || + hasConsented || + hasDismissedPrompt + ) { + return; + } + + showConsentPrompt(); + }, [ + featureEnabled, + loadedSettings, + hasConsented, + hasDismissedPrompt, + showConsentPrompt, + ]); +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index db3a5cf33cf..1fcfc9b467b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -33,6 +33,10 @@ export const discussedFeedVersion = new Feature('discussed_feed_version', 2); export const latestFeedVersion = new Feature('latest_feed_version', 2); export const customFeedVersion = new Feature('custom_feed_version', 2); export const featureNoAiFeed = new Feature('no_ai_feed', false); +export const featureCompanionBrowsingConsent = new Feature( + 'companion_browsing_consent', + false, +); export const featureFeedV2Highlights = new Feature('feed_v2_highlights', false); // @ts-expect-error stale feature without default diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 9eafc7f1f75..3d7fcd598d2 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -280,6 +280,11 @@ export enum LogEvent { // End Clickbait Shield ToggleNoAiFeed = 'toggle no ai feed', SaveNoAiFeedPreference = 'save no ai feed preference', + // Browsing Context Consent + BrowsingConsentPromptShow = 'browsing consent prompt show', + BrowsingConsentPromptAccept = 'browsing consent prompt accept', + BrowsingConsentPromptDecline = 'browsing consent prompt decline', + // End Browsing Context Consent InstallPWA = 'install pwa', // Start Share ShareProfile = 'share profile', From 6359d009e8ea654041667c4eede4b0ff9964f2ce Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 8 Apr 2026 13:58:19 +0000 Subject: [PATCH 2/3] refactor(companion): replace modal consent with inline banner showPrompt() modals are not visible inside the companion shadow DOM. Replace with an inline CompanionBrowsingConsentBanner rendered directly in CompanionContent so the consent prompt is visible to users. Co-Authored-By: Claude Opus 4.6 --- .../extension/src/companion/Companion.tsx | 2 - .../CompanionBrowsingConsentBanner.tsx | 52 ++++++++++ .../src/companion/CompanionContent.tsx | 10 ++ .../hooks/useCompanionBrowsingConsent.spec.ts | 82 ++++++---------- .../src/hooks/useCompanionBrowsingConsent.ts | 98 ++++++++----------- 5 files changed, 130 insertions(+), 114 deletions(-) create mode 100644 packages/extension/src/companion/CompanionBrowsingConsentBanner.tsx diff --git a/packages/extension/src/companion/Companion.tsx b/packages/extension/src/companion/Companion.tsx index 37fb1e1b242..a975a74d443 100644 --- a/packages/extension/src/companion/Companion.tsx +++ b/packages/extension/src/companion/Companion.tsx @@ -12,7 +12,6 @@ import type { } from '@dailydotdev/shared/src/lib/boot'; import useLogPageView from '@dailydotdev/shared/src/hooks/log/useLogPageView'; import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; -import { useCompanionBrowsingConsent } from '@dailydotdev/shared/src/hooks/useCompanionBrowsingConsent'; import { usePopupSelector } from '@dailydotdev/shared/src/hooks/usePopupSelector'; import { useBackgroundRequest } from '@dailydotdev/shared/src/hooks/companion'; import { @@ -86,7 +85,6 @@ export default function Companion({ useBackgroundRequest(refreshTokenKey, { callback: ({ res }) => onUpdateToken(res.accessToken), }); - useCompanionBrowsingConsent(); const containerRef = useRef(); const [assetsLoaded, setAssetsLoaded] = useState(isTesting); usePopupSelector({ parentSelector: getCompanionWrapper }); diff --git a/packages/extension/src/companion/CompanionBrowsingConsentBanner.tsx b/packages/extension/src/companion/CompanionBrowsingConsentBanner.tsx new file mode 100644 index 00000000000..9e2bb9676ef --- /dev/null +++ b/packages/extension/src/companion/CompanionBrowsingConsentBanner.tsx @@ -0,0 +1,52 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import CloseButton from '@dailydotdev/shared/src/components/CloseButton'; +import type { UseCompanionBrowsingConsentReturn } from '@dailydotdev/shared/src/hooks/useCompanionBrowsingConsent'; + +type CompanionBrowsingConsentBannerProps = Pick< + UseCompanionBrowsingConsentReturn, + 'onAccept' | 'onDismiss' +>; + +export function CompanionBrowsingConsentBanner({ + onAccept, + onDismiss, +}: CompanionBrowsingConsentBannerProps): ReactElement { + return ( +
+ +

+ Personalize with browsing context? +

+

+ Allow daily.dev to use your browsing context for more relevant + recommendations. Change anytime in settings. +

+
+ + +
+
+ ); +} diff --git a/packages/extension/src/companion/CompanionContent.tsx b/packages/extension/src/companion/CompanionContent.tsx index 65061a9ae0e..acf9af92b97 100644 --- a/packages/extension/src/companion/CompanionContent.tsx +++ b/packages/extension/src/companion/CompanionContent.tsx @@ -21,8 +21,10 @@ import { Origin } from '@dailydotdev/shared/src/lib/log'; import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { useCompanionBrowsingConsent } from '@dailydotdev/shared/src/hooks/useCompanionBrowsingConsent'; import { CompanionEngagements } from './CompanionEngagements'; import { CompanionDiscussion } from './CompanionDiscussion'; +import { CompanionBrowsingConsentBanner } from './CompanionBrowsingConsentBanner'; import { useBackgroundPaginatedRequest } from './useBackgroundPaginatedRequest'; type CompanionContentProps = { @@ -35,6 +37,8 @@ export default function CompanionContent({ post, }: CompanionContentProps): ReactElement { const { logEvent } = useLogContext(); + const { shouldShowBanner, onAccept, onDismiss } = + useCompanionBrowsingConsent(); const [copying, copyLink] = useCopyLink(() => post.commentsPermalink); const [heightPx, setHeightPx] = useState('0'); const { queryKey, onShowUpvoted } = useUpvoteQuery(); @@ -83,6 +87,12 @@ export default function CompanionContent({ /> + {shouldShowBanner && ( + + )}

TLDR - diff --git a/packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts b/packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts index c77479e2679..8c4192a6375 100644 --- a/packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts +++ b/packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts @@ -2,7 +2,6 @@ import { renderHook, act } from '@testing-library/react'; import { useCompanionBrowsingConsent } from './useCompanionBrowsingConsent'; import { useConditionalFeature } from './useConditionalFeature'; import { useSettingsContext } from '../contexts/SettingsContext'; -import { usePrompt } from './usePrompt'; import { useLogContext } from '../contexts/LogContext'; import { LogEvent, Origin } from '../lib/log'; import { SidebarSettingsFlags } from '../graphql/settings'; @@ -15,23 +14,17 @@ jest.mock('../contexts/SettingsContext', () => ({ useSettingsContext: jest.fn(), })); -jest.mock('./usePrompt', () => ({ - usePrompt: jest.fn(), -})); - jest.mock('../contexts/LogContext', () => ({ useLogContext: jest.fn(), })); const mockUseConditionalFeature = useConditionalFeature as jest.Mock; const mockUseSettingsContext = useSettingsContext as jest.Mock; -const mockUsePrompt = usePrompt as jest.Mock; const mockUseLogContext = useLogContext as jest.Mock; describe('useCompanionBrowsingConsent', () => { const updateFlag = jest.fn().mockResolvedValue(undefined); const updatePromptFlag = jest.fn().mockResolvedValue(undefined); - const showPrompt = jest.fn(); const logEvent = jest.fn(); beforeEach(() => { @@ -50,24 +43,13 @@ describe('useCompanionBrowsingConsent', () => { updateFlag, updatePromptFlag, }); - mockUsePrompt.mockReturnValue({ showPrompt }); mockUseLogContext.mockReturnValue({ logEvent }); }); - it('shows the consent prompt when feature is enabled and user has not seen it', async () => { - showPrompt.mockResolvedValue(true); - - await act(async () => { - renderHook(() => useCompanionBrowsingConsent()); - }); + it('shows the banner when feature is enabled and user has not seen it', () => { + const { result } = renderHook(() => useCompanionBrowsingConsent()); - expect(showPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Personalize your feed with browsing context?', - okButton: { title: 'Allow' }, - cancelButton: { title: 'No thanks' }, - }), - ); + expect(result.current.shouldShowBanner).toBe(true); expect(logEvent).toHaveBeenCalledWith({ event_name: LogEvent.BrowsingConsentPromptShow, extra: JSON.stringify({ origin: Origin.Companion }), @@ -75,58 +57,56 @@ describe('useCompanionBrowsingConsent', () => { }); it('saves browsingContextEnabled flag when user accepts', async () => { - showPrompt.mockResolvedValue(true); + const { result } = renderHook(() => useCompanionBrowsingConsent()); await act(async () => { - renderHook(() => useCompanionBrowsingConsent()); + await result.current.onAccept(); }); expect(updateFlag).toHaveBeenCalledWith( SidebarSettingsFlags.BrowsingContextEnabled, true, ); - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.BrowsingConsentPromptAccept, - extra: JSON.stringify({ origin: Origin.Companion }), - }); expect(updatePromptFlag).toHaveBeenCalledWith( 'browsing_context_consent_prompt', true, ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.BrowsingConsentPromptAccept, + extra: JSON.stringify({ origin: Origin.Companion }), + }); }); - it('does not save browsingContextEnabled when user declines', async () => { - showPrompt.mockResolvedValue(false); + it('does not save browsingContextEnabled when user dismisses', async () => { + const { result } = renderHook(() => useCompanionBrowsingConsent()); await act(async () => { - renderHook(() => useCompanionBrowsingConsent()); + await result.current.onDismiss(); }); expect(updateFlag).not.toHaveBeenCalled(); - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.BrowsingConsentPromptDecline, - extra: JSON.stringify({ origin: Origin.Companion }), - }); expect(updatePromptFlag).toHaveBeenCalledWith( 'browsing_context_consent_prompt', true, ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.BrowsingConsentPromptDecline, + extra: JSON.stringify({ origin: Origin.Companion }), + }); }); - it('does not show prompt when feature is disabled', async () => { + it('does not show banner when feature is disabled', () => { mockUseConditionalFeature.mockReturnValue({ value: false, isLoading: false, }); - await act(async () => { - renderHook(() => useCompanionBrowsingConsent()); - }); + const { result } = renderHook(() => useCompanionBrowsingConsent()); - expect(showPrompt).not.toHaveBeenCalled(); + expect(result.current.shouldShowBanner).toBe(false); }); - it('does not show prompt when user already consented', async () => { + it('does not show banner when user already consented', () => { mockUseSettingsContext.mockReturnValue({ flags: { browsingContextEnabled: true, @@ -137,14 +117,12 @@ describe('useCompanionBrowsingConsent', () => { updatePromptFlag, }); - await act(async () => { - renderHook(() => useCompanionBrowsingConsent()); - }); + const { result } = renderHook(() => useCompanionBrowsingConsent()); - expect(showPrompt).not.toHaveBeenCalled(); + expect(result.current.shouldShowBanner).toBe(false); }); - it('does not show prompt when user already dismissed it', async () => { + it('does not show banner when user already dismissed it', () => { mockUseSettingsContext.mockReturnValue({ flags: { browsingContextEnabled: false, @@ -155,14 +133,12 @@ describe('useCompanionBrowsingConsent', () => { updatePromptFlag, }); - await act(async () => { - renderHook(() => useCompanionBrowsingConsent()); - }); + const { result } = renderHook(() => useCompanionBrowsingConsent()); - expect(showPrompt).not.toHaveBeenCalled(); + expect(result.current.shouldShowBanner).toBe(false); }); - it('does not show prompt when settings are not loaded', async () => { + it('does not show banner when settings are not loaded', () => { mockUseSettingsContext.mockReturnValue({ flags: { browsingContextEnabled: false, @@ -173,10 +149,8 @@ describe('useCompanionBrowsingConsent', () => { updatePromptFlag, }); - await act(async () => { - renderHook(() => useCompanionBrowsingConsent()); - }); + const { result } = renderHook(() => useCompanionBrowsingConsent()); - expect(showPrompt).not.toHaveBeenCalled(); + expect(result.current.shouldShowBanner).toBe(false); }); }); diff --git a/packages/shared/src/hooks/useCompanionBrowsingConsent.ts b/packages/shared/src/hooks/useCompanionBrowsingConsent.ts index 82958bd13b9..d3c5888672e 100644 --- a/packages/shared/src/hooks/useCompanionBrowsingConsent.ts +++ b/packages/shared/src/hooks/useCompanionBrowsingConsent.ts @@ -2,79 +2,61 @@ import { useCallback, useEffect, useRef } from 'react'; import { useSettingsContext } from '../contexts/SettingsContext'; import { useConditionalFeature } from './useConditionalFeature'; import { featureCompanionBrowsingConsent } from '../lib/featureManagement'; -import { usePrompt } from './usePrompt'; import { useLogContext } from '../contexts/LogContext'; import { LogEvent, Origin } from '../lib/log'; import { SidebarSettingsFlags } from '../graphql/settings'; const BROWSING_CONSENT_PROMPT_FLAG = 'browsing_context_consent_prompt'; -export const useCompanionBrowsingConsent = (): void => { - const { value: featureEnabled } = useConditionalFeature({ - feature: featureCompanionBrowsingConsent, - shouldEvaluate: true, - }); - const { flags, loadedSettings, updateFlag, updatePromptFlag } = - useSettingsContext(); - const { showPrompt } = usePrompt(); - const { logEvent } = useLogContext(); - const promptShownRef = useRef(false); - - const hasConsented = !!flags?.browsingContextEnabled; - const hasDismissedPrompt = !!flags?.prompt?.[BROWSING_CONSENT_PROMPT_FLAG]; - - const showConsentPrompt = useCallback(async () => { - if (promptShownRef.current) { - return; - } - - promptShownRef.current = true; - - logEvent({ - event_name: LogEvent.BrowsingConsentPromptShow, - extra: JSON.stringify({ origin: Origin.Companion }), - }); +export type UseCompanionBrowsingConsentReturn = { + shouldShowBanner: boolean; + onAccept: () => Promise; + onDismiss: () => Promise; +}; - const accepted = await showPrompt({ - title: 'Personalize your feed with browsing context?', - description: - 'Allow daily.dev to use your browsing context to recommend more relevant content. You can change this anytime in settings.', - okButton: { title: 'Allow' }, - cancelButton: { title: 'No thanks' }, +export const useCompanionBrowsingConsent = + (): UseCompanionBrowsingConsentReturn => { + const { value: featureEnabled } = useConditionalFeature({ + feature: featureCompanionBrowsingConsent, + shouldEvaluate: true, }); - - if (accepted) { + const { flags, loadedSettings, updateFlag, updatePromptFlag } = + useSettingsContext(); + const { logEvent } = useLogContext(); + const impressionLoggedRef = useRef(false); + + const hasConsented = !!flags?.browsingContextEnabled; + const hasDismissedPrompt = !!flags?.prompt?.[BROWSING_CONSENT_PROMPT_FLAG]; + + const shouldShowBanner = + featureEnabled && loadedSettings && !hasConsented && !hasDismissedPrompt; + + useEffect(() => { + if (shouldShowBanner && !impressionLoggedRef.current) { + impressionLoggedRef.current = true; + logEvent({ + event_name: LogEvent.BrowsingConsentPromptShow, + extra: JSON.stringify({ origin: Origin.Companion }), + }); + } + }, [shouldShowBanner, logEvent]); + + const onAccept = useCallback(async () => { await updateFlag(SidebarSettingsFlags.BrowsingContextEnabled, true); + await updatePromptFlag(BROWSING_CONSENT_PROMPT_FLAG, true); logEvent({ event_name: LogEvent.BrowsingConsentPromptAccept, extra: JSON.stringify({ origin: Origin.Companion }), }); - } else { + }, [updateFlag, updatePromptFlag, logEvent]); + + const onDismiss = useCallback(async () => { + await updatePromptFlag(BROWSING_CONSENT_PROMPT_FLAG, true); logEvent({ event_name: LogEvent.BrowsingConsentPromptDecline, extra: JSON.stringify({ origin: Origin.Companion }), }); - } - - await updatePromptFlag(BROWSING_CONSENT_PROMPT_FLAG, true); - }, [logEvent, showPrompt, updateFlag, updatePromptFlag]); + }, [updatePromptFlag, logEvent]); - useEffect(() => { - if ( - !featureEnabled || - !loadedSettings || - hasConsented || - hasDismissedPrompt - ) { - return; - } - - showConsentPrompt(); - }, [ - featureEnabled, - loadedSettings, - hasConsented, - hasDismissedPrompt, - showConsentPrompt, - ]); -}; + return { shouldShowBanner, onAccept, onDismiss }; + }; From f114455a318bb8e86cdd8fc9357e48e9b6b980a1 Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 8 Apr 2026 17:18:50 +0200 Subject: [PATCH 3/3] fix(companion): wire up settings state and add consent notification badge Pass loadedSettings and updateSettings to companion SettingsContextProvider so the browsing consent hook can read and write flags. Lift consent hook to Companion component and add a purple notification dot on the toggle icon when the banner is pending (hidden on hover). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/extension/src/companion/App.tsx | 16 +++++++++++++--- packages/extension/src/companion/Companion.tsx | 11 ++++++++++- .../extension/src/companion/CompanionContent.tsx | 9 +++++---- .../extension/src/companion/CompanionMenu.tsx | 3 +++ .../extension/src/companion/CompanionToggle.tsx | 9 +++++++-- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/companion/App.tsx b/packages/extension/src/companion/App.tsx index e884eea00d2..eb7de8a2e3b 100644 --- a/packages/extension/src/companion/App.tsx +++ b/packages/extension/src/companion/App.tsx @@ -24,6 +24,7 @@ import { GrowthBookProvider } from '@dailydotdev/shared/src/components/GrowthBoo import { NotificationsContextProvider } from '@dailydotdev/shared/src/contexts/NotificationsContext'; import { useEventListener } from '@dailydotdev/shared/src/hooks'; import { structuredCloneJsonPolyfill } from '@dailydotdev/shared/src/lib/structuredClone'; +import type { RemoteSettings } from '@dailydotdev/shared/src/graphql/settings'; import Companion from './Companion'; import CustomRouter from '../lib/CustomRouter'; import { companionFetch } from './companionFetch'; @@ -62,6 +63,9 @@ export default function App({ }: CompanionData): ReactElement | null { useError(); const [token, setToken] = useState(accessToken); + const [currentSettings, setCurrentSettings] = useState(settings); + const updateSettings = (newSettings: RemoteSettings) => + setCurrentSettings(newSettings); const [isOptOutCompanion, setIsOptOutCompanion] = useState( settings?.optOutCompanion ?? false, ); @@ -109,7 +113,11 @@ export default function App({ updateUser={async () => undefined} squads={squads} > - + setIsOptOutCompanion(true)} onUpdateToken={setToken} /> @@ -138,7 +148,7 @@ export default function App({ /> diff --git a/packages/extension/src/companion/Companion.tsx b/packages/extension/src/companion/Companion.tsx index a975a74d443..a60225500e8 100644 --- a/packages/extension/src/companion/Companion.tsx +++ b/packages/extension/src/companion/Companion.tsx @@ -19,6 +19,7 @@ import { updatePostCache, } from '@dailydotdev/shared/src/lib/query'; import { getCompanionWrapper } from '@dailydotdev/shared/src/lib/extension'; +import { useCompanionBrowsingConsent } from '@dailydotdev/shared/src/hooks/useCompanionBrowsingConsent'; import CompanionMenu from './CompanionMenu'; import CompanionContent from './CompanionContent'; import { companionRequest } from './companionRequest'; @@ -87,6 +88,8 @@ export default function Companion({ }); const containerRef = useRef(); const [assetsLoaded, setAssetsLoaded] = useState(isTesting); + const { shouldShowBanner, onAccept, onDismiss } = + useCompanionBrowsingConsent(); usePopupSelector({ parentSelector: getCompanionWrapper }); const client = useQueryClient(); const { data: post } = useQuery({ @@ -164,8 +167,14 @@ export default function Companion({ setVerticalPosition={setVerticalPosition} isDragging={isDragging} setIsDragging={setIsDragging} + showConsentNotification={shouldShowBanner} + /> + - ); } diff --git a/packages/extension/src/companion/CompanionContent.tsx b/packages/extension/src/companion/CompanionContent.tsx index acf9af92b97..af7478e8c63 100644 --- a/packages/extension/src/companion/CompanionContent.tsx +++ b/packages/extension/src/companion/CompanionContent.tsx @@ -21,7 +21,7 @@ import { Origin } from '@dailydotdev/shared/src/lib/log'; import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; -import { useCompanionBrowsingConsent } from '@dailydotdev/shared/src/hooks/useCompanionBrowsingConsent'; +import type { UseCompanionBrowsingConsentReturn } from '@dailydotdev/shared/src/hooks/useCompanionBrowsingConsent'; import { CompanionEngagements } from './CompanionEngagements'; import { CompanionDiscussion } from './CompanionDiscussion'; import { CompanionBrowsingConsentBanner } from './CompanionBrowsingConsentBanner'; @@ -29,16 +29,17 @@ import { useBackgroundPaginatedRequest } from './useBackgroundPaginatedRequest'; type CompanionContentProps = { post: PostBootData; -}; +} & UseCompanionBrowsingConsentReturn; const COMPANION_TOP_OFFSET_PX = 120; export default function CompanionContent({ post, + shouldShowBanner, + onAccept, + onDismiss, }: CompanionContentProps): ReactElement { const { logEvent } = useLogContext(); - const { shouldShowBanner, onAccept, onDismiss } = - useCompanionBrowsingConsent(); const [copying, copyLink] = useCopyLink(() => post.commentsPermalink); const [heightPx, setHeightPx] = useState('0'); const { queryKey, onShowUpvoted } = useUpvoteQuery(); diff --git a/packages/extension/src/companion/CompanionMenu.tsx b/packages/extension/src/companion/CompanionMenu.tsx index 48711e5351e..b1b538cb023 100644 --- a/packages/extension/src/companion/CompanionMenu.tsx +++ b/packages/extension/src/companion/CompanionMenu.tsx @@ -74,6 +74,7 @@ type CompanionMenuProps = { isDragging: boolean; setIsDragging: (dragging: boolean) => void; onOpenComments?: () => void; + showConsentNotification?: boolean; }; export default function CompanionMenu({ @@ -87,6 +88,7 @@ export default function CompanionMenu({ setVerticalPosition, isDragging, setIsDragging, + showConsentNotification, }: CompanionMenuProps): ReactElement { const { modal, closeModal } = useLazyModal(); const { logEvent } = useLogContext(); @@ -355,6 +357,7 @@ export default function CompanionMenu({ isAlertDisabled={!showCompanionHelper} tooltipContainerClassName={tooltipContainerClassName} onToggleCompanion={toggleCompanion} + showNotification={showConsentNotification && !companionState} /> void; tooltipContainerClassName?: string; + showNotification?: boolean; } function CompanionToggle({ @@ -25,6 +26,7 @@ function CompanionToggle({ isAlertDisabled, tooltipContainerClassName, onToggleCompanion, + showNotification, }: CompanionToggleProps): ReactElement { return ( + - + {!!showNotification && ( + + )} + } onClick={onToggleCompanion} />