diff --git a/packages/extension/src/companion/App.tsx b/packages/extension/src/companion/App.tsx index e884eea00d..eb7de8a2e3 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 a975a74d44..a60225500e 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/CompanionBrowsingConsentBanner.tsx b/packages/extension/src/companion/CompanionBrowsingConsentBanner.tsx new file mode 100644 index 0000000000..9e2bb9676e --- /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 65061a9ae0..af7478e8c6 100644 --- a/packages/extension/src/companion/CompanionContent.tsx +++ b/packages/extension/src/companion/CompanionContent.tsx @@ -21,18 +21,23 @@ 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 type { UseCompanionBrowsingConsentReturn } from '@dailydotdev/shared/src/hooks/useCompanionBrowsingConsent'; import { CompanionEngagements } from './CompanionEngagements'; import { CompanionDiscussion } from './CompanionDiscussion'; +import { CompanionBrowsingConsentBanner } from './CompanionBrowsingConsentBanner'; 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 [copying, copyLink] = useCopyLink(() => post.commentsPermalink); @@ -83,6 +88,12 @@ export default function CompanionContent({ /> + {shouldShowBanner && ( + + )}

TLDR - diff --git a/packages/extension/src/companion/CompanionMenu.tsx b/packages/extension/src/companion/CompanionMenu.tsx index 48711e5351..b1b538cb02 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} /> diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 5fead6dc4e..eed0d5e771 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 0000000000..8c4192a637 --- /dev/null +++ b/packages/shared/src/hooks/useCompanionBrowsingConsent.spec.ts @@ -0,0 +1,156 @@ +import { renderHook, act } from '@testing-library/react'; +import { useCompanionBrowsingConsent } from './useCompanionBrowsingConsent'; +import { useConditionalFeature } from './useConditionalFeature'; +import { useSettingsContext } from '../contexts/SettingsContext'; +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('../contexts/LogContext', () => ({ + useLogContext: jest.fn(), +})); + +const mockUseConditionalFeature = useConditionalFeature as jest.Mock; +const mockUseSettingsContext = useSettingsContext as jest.Mock; +const mockUseLogContext = useLogContext as jest.Mock; + +describe('useCompanionBrowsingConsent', () => { + const updateFlag = jest.fn().mockResolvedValue(undefined); + const updatePromptFlag = jest.fn().mockResolvedValue(undefined); + const logEvent = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseConditionalFeature.mockReturnValue({ + value: true, + isLoading: false, + }); + mockUseSettingsContext.mockReturnValue({ + flags: { + browsingContextEnabled: false, + prompt: {}, + }, + loadedSettings: true, + updateFlag, + updatePromptFlag, + }); + mockUseLogContext.mockReturnValue({ logEvent }); + }); + + it('shows the banner when feature is enabled and user has not seen it', () => { + const { result } = renderHook(() => useCompanionBrowsingConsent()); + + expect(result.current.shouldShowBanner).toBe(true); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.BrowsingConsentPromptShow, + extra: JSON.stringify({ origin: Origin.Companion }), + }); + }); + + it('saves browsingContextEnabled flag when user accepts', async () => { + const { result } = renderHook(() => useCompanionBrowsingConsent()); + + await act(async () => { + await result.current.onAccept(); + }); + + expect(updateFlag).toHaveBeenCalledWith( + SidebarSettingsFlags.BrowsingContextEnabled, + true, + ); + 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 dismisses', async () => { + const { result } = renderHook(() => useCompanionBrowsingConsent()); + + await act(async () => { + await result.current.onDismiss(); + }); + + expect(updateFlag).not.toHaveBeenCalled(); + 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 banner when feature is disabled', () => { + mockUseConditionalFeature.mockReturnValue({ + value: false, + isLoading: false, + }); + + const { result } = renderHook(() => useCompanionBrowsingConsent()); + + expect(result.current.shouldShowBanner).toBe(false); + }); + + it('does not show banner when user already consented', () => { + mockUseSettingsContext.mockReturnValue({ + flags: { + browsingContextEnabled: true, + prompt: {}, + }, + loadedSettings: true, + updateFlag, + updatePromptFlag, + }); + + const { result } = renderHook(() => useCompanionBrowsingConsent()); + + expect(result.current.shouldShowBanner).toBe(false); + }); + + it('does not show banner when user already dismissed it', () => { + mockUseSettingsContext.mockReturnValue({ + flags: { + browsingContextEnabled: false, + prompt: { browsing_context_consent_prompt: true }, + }, + loadedSettings: true, + updateFlag, + updatePromptFlag, + }); + + const { result } = renderHook(() => useCompanionBrowsingConsent()); + + expect(result.current.shouldShowBanner).toBe(false); + }); + + it('does not show banner when settings are not loaded', () => { + mockUseSettingsContext.mockReturnValue({ + flags: { + browsingContextEnabled: false, + prompt: {}, + }, + loadedSettings: false, + updateFlag, + updatePromptFlag, + }); + + const { result } = renderHook(() => useCompanionBrowsingConsent()); + + expect(result.current.shouldShowBanner).toBe(false); + }); +}); diff --git a/packages/shared/src/hooks/useCompanionBrowsingConsent.ts b/packages/shared/src/hooks/useCompanionBrowsingConsent.ts new file mode 100644 index 0000000000..d3c5888672 --- /dev/null +++ b/packages/shared/src/hooks/useCompanionBrowsingConsent.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useSettingsContext } from '../contexts/SettingsContext'; +import { useConditionalFeature } from './useConditionalFeature'; +import { featureCompanionBrowsingConsent } from '../lib/featureManagement'; +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 type UseCompanionBrowsingConsentReturn = { + shouldShowBanner: boolean; + onAccept: () => Promise; + onDismiss: () => Promise; +}; + +export const useCompanionBrowsingConsent = + (): UseCompanionBrowsingConsentReturn => { + const { value: featureEnabled } = useConditionalFeature({ + feature: featureCompanionBrowsingConsent, + shouldEvaluate: true, + }); + 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 }), + }); + }, [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 }), + }); + }, [updatePromptFlag, logEvent]); + + return { shouldShowBanner, onAccept, onDismiss }; + }; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index db3a5cf33c..1fcfc9b467 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 9eafc7f1f7..3d7fcd598d 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',