diff --git a/apps/app/src/api/ads-init.js b/apps/app/src/api/ads-init.js index 109fcbe924..98871c3f0b 100644 --- a/apps/app/src/api/ads-init.js +++ b/apps/app/src/api/ads-init.js @@ -1,5 +1,139 @@ const MODRINTH_ORIGIN = 'https://modrinth.com' +function installAdsConsentThemeStyle() { + if (document.getElementById('modrinth-ads-consent-theme-style')) return + + const style = document.createElement('style') + style.id = 'modrinth-ads-consent-theme-style' + style.textContent = ` + :root { + --modrinth-usp-bg: #27292e; + --modrinth-usp-surface: #34363c; + --modrinth-usp-divider: #34363c; + --modrinth-usp-text: #b0bac5; + --modrinth-usp-contrast: #ffffff; + --modrinth-usp-brand: #1bd96a; + --modrinth-usp-link: #4f9cff; + --modrinth-usp-accent-contrast: #000000; + --modrinth-usp-shadow: rgba(0, 0, 0, 0.1) 0 4px 6px -1px, + rgba(0, 0, 0, 0.06) 0 2px 4px -1px; + color-scheme: dark; + } + + #qc-cmp2-usp { + background: var(--modrinth-usp-bg) !important; + border: 1px solid var(--modrinth-usp-divider) !important; + border-radius: 1rem !important; + box-shadow: var(--modrinth-usp-shadow) !important; + color: var(--modrinth-usp-text) !important; + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Roboto, + Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif !important; + max-width: 660px; + } + + #qc-cmp2-usp .qc-usp-ui-content, + #qc-cmp2-usp .qc-usp-ui-form-content, + #qc-cmp2-usp .qc-usp-container { + background: transparent !important; + } + #qc-cmp2-usp .qc-usp-container{ + margin-bottom: 12px; + } + + #qc-cmp2-usp p, + #qc-cmp2-usp label, + #qc-cmp2-usp .qc-usp-action-description { + color: var(--modrinth-usp-text) !important; + font-family: inherit !important; + } + + #qc-cmp2-usp .qc-usp-title, + #qc-cmp2-usp .qc-cmp2-list-item-title { + color: var(--modrinth-usp-contrast) !important; + font-family: inherit !important; + font-weight: 700 !important; + } + + #qc-cmp2-usp .qc-usp-title { + font-size: 1.25rem !important; + } + + #qc-cmp2-usp a, + #qc-cmp2-usp .qc-usp-alt-action { + color: var(--modrinth-usp-link) !important; + } + + #qc-cmp2-usp .qc-cmp2-list-item { + background: var(--modrinth-usp-surface) !important; + border: 1px solid var(--modrinth-usp-divider) !important; + border-radius: 0.75rem !important; + } + + #qc-cmp2-usp .qc-cmp2-list-item-header { + background: transparent !important; + border: 0 !important; + color: var(--modrinth-usp-contrast) !important; + } + + #qc-cmp2-usp .qc-cmp2-list-item-header svg { + color: var(--modrinth-usp-text) !important; + } + + #qc-cmp2-usp .qc-cmp2-toggle { + background: var(--modrinth-usp-bg) !important; + border-color: var(--modrinth-usp-divider) !important; + } + + #qc-cmp2-usp .qc-cmp2-toggle .toggle { + background: var(--modrinth-usp-contrast) !important; + } + + #qc-cmp2-usp .qc-cmp2-toggle .text { + color: var(--modrinth-usp-contrast) !important; + } + + #qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] { + background: var(--modrinth-usp-brand) !important; + border-color: var(--modrinth-usp-brand) !important; + } + + #qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] .text { + color: var(--modrinth-usp-accent-contrast) !important; + } + + #qc-cmp2-usp button[mode='primary'] { + background: var(--modrinth-usp-brand) !important; + border: 0 !important; + border-radius: 0.75rem !important; + color: var(--modrinth-usp-accent-contrast) !important; + font-family: inherit !important; + font-weight: 700 !important; + } + + #qc-cmp2-usp .qc-usp-close-icon { + border: 0 !important; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") + center / 1.5rem 1.5rem no-repeat; + } + + #qc-cmp2-usp a:focus-visible, + #qc-cmp2-usp button:focus-visible { + outline: 2px solid var(--modrinth-usp-brand) !important; + outline-offset: 2px !important; + } + + #qc-cmp2-usp .qc-usp-ui-content { + max-width: 100% !important; + } + + #qc-cmp2-usp .qc-usp-ui-content .qc-usp-ui-form-content { + border: 1px solid transparent !important; + padding: 0 !important; + } + ` + document.documentElement.appendChild(style) +} + document.addEventListener( 'click', function (e) { @@ -811,6 +945,7 @@ function muteVideos() { } document.addEventListener('DOMContentLoaded', () => { + installAdsConsentThemeStyle() installAdsRailStyle() installAdsConsentOverlayStyle() muteVideos() diff --git a/apps/frontend/src/assets/styles/global.scss b/apps/frontend/src/assets/styles/global.scss index 7013d7b54a..56214eba8c 100644 --- a/apps/frontend/src/assets/styles/global.scss +++ b/apps/frontend/src/assets/styles/global.scss @@ -487,3 +487,109 @@ input { background-color: transparent; color: hsl(145, 78%, 28%); } + +#qc-cmp2-usp { + background: var(--color-raised-bg) !important; + border: 1px solid var(--color-divider) !important; + border-radius: var(--radius-lg) !important; + box-shadow: var(--shadow-floating) !important; + color: var(--color-base) !important; + font-family: var(--font-standard) !important; + max-width: 660px; + + .qc-usp-ui-content, + .qc-usp-ui-form-content, + .qc-usp-container { + background: transparent !important; + } + + p, + label, + .qc-usp-action-description { + color: var(--color-base) !important; + font-family: var(--font-standard) !important; + } + + .qc-usp-title, + .qc-cmp2-list-item-title { + color: var(--color-contrast) !important; + font-family: var(--font-standard) !important; + font-weight: 700 !important; + } + + .qc-usp-title { + font-size: 1.25rem !important; + } + + a, + .qc-usp-alt-action { + color: var(--color-link) !important; + } + + .qc-cmp2-list-item { + background: var(--color-button-bg) !important; + border: 1px solid var(--color-divider) !important; + border-radius: var(--radius-md) !important; + } + + .qc-cmp2-list-item-header { + background: transparent !important; + border: 0 !important; + color: var(--color-contrast) !important; + } + + .qc-cmp2-list-item-header svg { + color: var(--color-base) !important; + } + + .qc-cmp2-toggle { + background: var(--surface-3) !important; + border-color: var(--color-divider) !important; + } + + .qc-cmp2-toggle .toggle { + background: var(--color-contrast) !important; + } + + .qc-cmp2-toggle .text { + color: var(--color-contrast) !important; + } + + .qc-cmp2-toggle[aria-checked='true'] { + background: var(--color-brand) !important; + border-color: var(--color-brand) !important; + } + + .qc-cmp2-toggle[aria-checked='true'] .text { + color: var(--color-accent-contrast) !important; + } + + button[mode='primary'] { + background: var(--color-brand) !important; + border: 0 !important; + border-radius: var(--radius-md) !important; + color: var(--color-accent-contrast) !important; + font-family: var(--font-standard) !important; + font-weight: 700 !important; + } + + .qc-usp-close-icon { + border: 0 !important; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") + center / 1.5rem 1.5rem no-repeat; + } + + a:focus-visible, + button:focus-visible { + outline: 2px solid var(--color-brand) !important; + outline-offset: 2px !important; + } + + .qc-usp-ui-content { + max-width: 100% !important; + } + .qc-usp-ui-content .qc-usp-ui-form-content { + border: 1px solid transparent !important; + padding: 0 !important; + } +} diff --git a/apps/frontend/src/components/ui/AdsConsentNotification.vue b/apps/frontend/src/components/ui/AdsConsentNotification.vue index 49e3a5d851..4734b96260 100644 --- a/apps/frontend/src/components/ui/AdsConsentNotification.vue +++ b/apps/frontend/src/components/ui/AdsConsentNotification.vue @@ -8,6 +8,7 @@ import { import { onBeforeUnmount, onMounted } from 'vue' type ConsentAction = 'accept' | 'reject' | 'manage' +type ConsentVariant = 'tcf' | 'usp' interface TcfData { eventStatus?: string @@ -17,6 +18,19 @@ interface TcfData { type TcfCallback = (data: TcfData, success: boolean) => void type TcfApi = (command: string, version: number, callback: TcfCallback, parameter?: unknown) => void +interface UspControls { + toggles: HTMLButtonElement[] + confirmButton: HTMLButtonElement +} + +interface GppData { + eventName?: string + listenerId?: number +} + +type GppCallback = (data: GppData, success: boolean) => void +type GppApi = (command: string, callback: GppCallback, parameter?: unknown) => void + const CMP_HIDDEN_CLASS = 'modrinth-cmp-summary-hidden' const notificationManager = injectNotificationManager() const { formatMessage } = useVIntl() @@ -47,8 +61,17 @@ const messages = defineMessages({ let notificationId: WebNotification['id'] | null = null let tcfListenerId: number | undefined +let gppListenerId: number | undefined let listenerInstalled = false +let gppListenerInstalled = false +let gppListenerAttempts = 0 +let gppInstallTimeout: ReturnType | undefined let managingPreferences = false +let consentComplete = false +let consentVariant: ConsentVariant | null = null +let uspConsentCommitPending = false +let uspSuccessModalDismissed = false +let uspCommitTimeout: ReturnType | undefined let consentContainerObserver: MutationObserver | undefined const consentContainerContains = new Map() @@ -56,31 +79,132 @@ function getTcfApi(): TcfApi | undefined { return (window as typeof window & { __tcfapi?: TcfApi }).__tcfapi } +function getGppApi(): GppApi | undefined { + return (window as typeof window & { __gpp?: GppApi }).__gpp +} + function setConsentUiHidden(hidden: boolean) { document.documentElement.classList.toggle(CMP_HIDDEN_CLASS, hidden) patchConsentFocusTrapContainer() } function patchConsentFocusTrapContainer() { - const container = document.querySelector('#qc-cmp2-ui') - if (!container || consentContainerContains.has(container)) return + const containers = document.querySelectorAll('#qc-cmp2-ui, #qc-cmp2-usp') - const originalContains = container.contains - // InMobi's focus trap otherwise cancels clicks outside its hidden container. - container.contains = (node: Node | null) => - document.documentElement.classList.contains(CMP_HIDDEN_CLASS) || - originalContains.call(container, node) - consentContainerContains.set(container, originalContains) + for (const container of containers) { + if (consentContainerContains.has(container)) continue + + const originalContains = container.contains + // InMobi's focus trap otherwise cancels clicks outside its hidden container. + container.contains = (node: Node | null) => + document.documentElement.classList.contains(CMP_HIDDEN_CLASS) || + originalContains.call(container, node) + consentContainerContains.set(container, originalContains) + } +} + +function detectConsentVariant(): ConsentVariant | null { + let variant: ConsentVariant | null = null + + if (document.getElementById('qc-cmp2-usp')) { + variant = 'usp' + } else if (document.getElementById('qc-cmp2-ui')) { + variant = 'tcf' + } + + if (variant) consentVariant = variant + return variant } function getConsentContainers(): ParentNode[] { const containers = Array.from( - document.querySelectorAll('#qc-cmp2-container, #qc-cmp2-main, #qc-cmp2-ui'), + document.querySelectorAll( + '#qc-cmp2-container, #qc-cmp2-main, #qc-cmp2-ui, #qc-cmp2-usp', + ), ) return containers.length > 0 ? containers : [document] } +function getUspConsentControls(): UspControls | null { + const dialog = document.getElementById('qc-cmp2-usp') + if (!dialog) return null + + const toggles = Array.from( + dialog.querySelectorAll( + '.qc-usp-container button.qc-cmp2-toggle[role="switch"]', + ), + ) + const confirmButton = dialog.querySelector( + '.qc-usp-ui-form-content button[mode="primary"]', + ) + + if ( + toggles.length === 0 || + !confirmButton || + confirmButton.disabled || + toggles.some( + (toggle) => + toggle.disabled || !['true', 'false'].includes(toggle.getAttribute('aria-checked') ?? ''), + ) + ) { + return null + } + + return { toggles, confirmButton } +} + +function waitForUspToggleState( + index: number, + checked: boolean, + expectedCount: number, + deadline: number, +): Promise { + return new Promise((resolve) => { + function checkState() { + const controls = getUspConsentControls() + if ( + controls && + controls.toggles.length === expectedCount && + controls.toggles[index]?.getAttribute('aria-checked') === String(checked) + ) { + resolve(controls) + } else if (Date.now() >= deadline) { + resolve(null) + } else { + setTimeout(checkState, 50) + } + } + + checkState() + }) +} + +async function setUspToggleStates( + checked: boolean, + controls: UspControls, + timeoutMs: number, +): Promise { + const expectedCount = controls.toggles.length + const deadline = Date.now() + timeoutMs + + for (let index = 0; index < expectedCount; index += 1) { + const currentControls = getUspConsentControls() + if (!currentControls || currentControls.toggles.length !== expectedCount) return null + controls = currentControls + + const toggle = controls.toggles[index] + if (toggle.getAttribute('aria-checked') !== String(checked)) { + toggle.click() + const settledControls = await waitForUspToggleState(index, checked, expectedCount, deadline) + if (!settledControls) return null + controls = settledControls + } + } + + return controls +} + function matchesButtonText(button: HTMLButtonElement, terms: string[]): boolean { const text = [button.textContent, button.getAttribute('aria-label')] .filter(Boolean) @@ -173,9 +297,29 @@ function clickConsentButtonWhenReady(action: ConsentAction, timeoutMs: number): } async function performConsentAction(action: ConsentAction) { + const variant = detectConsentVariant() + if (action === 'manage') { managingPreferences = true setConsentUiHidden(false) + if (variant === 'usp') return + } + + if (variant === 'usp' && action !== 'manage') { + const controls = getUspConsentControls() + const settledControls = controls + ? await setUspToggleStates(action === 'reject', controls, 2000) + : null + + if (settledControls) { + beginUspConsentCommit() + settledControls.confirmButton.click() + return + } + + managingPreferences = false + setConsentUiHidden(false) + return } const clicked = await clickConsentButtonWhenReady(action, action === 'manage' ? 2500 : 1000) @@ -194,6 +338,8 @@ async function performConsentAction(action: ConsentAction) { } function showConsentNotification() { + if (consentComplete) return + setConsentUiHidden(true) if ( @@ -239,6 +385,10 @@ function showConsentNotification() { function finishConsent() { managingPreferences = false + consentComplete = true + uspConsentCommitPending = false + clearTimeout(uspCommitTimeout) + uspCommitTimeout = undefined setConsentUiHidden(false) if (notificationId !== null) { @@ -247,8 +397,67 @@ function finishConsent() { } } +function beginUspConsentCommit() { + if (!document.getElementById('qc-cmp2-usp')) return + + consentVariant = 'usp' + uspConsentCommitPending = true + uspSuccessModalDismissed = false + clearTimeout(uspCommitTimeout) + + const deadline = Date.now() + 2500 + function checkForDialogClosure() { + if (!uspConsentCommitPending) return + + if (!document.getElementById('qc-cmp2-usp')) { + finishConsent() + } else if (Date.now() >= deadline) { + uspConsentCommitPending = false + uspCommitTimeout = undefined + } else { + uspCommitTimeout = setTimeout(checkForDialogClosure, 50) + } + } + + uspCommitTimeout = setTimeout(checkForDialogClosure, 50) +} + +function dismissUspSuccessModal() { + if ((!uspConsentCommitPending && !consentComplete) || uspSuccessModalDismissed) return + + const closeButton = document.querySelector( + '#qc-cmp2-usp [aria-label="Close success modal"]', + ) + if (!closeButton || (closeButton instanceof HTMLButtonElement && closeButton.disabled)) return + + uspSuccessModalDismissed = true + closeButton.click() +} + +function syncConsentUi() { + patchConsentFocusTrapContainer() + installGppConsentListener() + const variant = detectConsentVariant() + dismissUspSuccessModal() + + if ( + variant === 'usp' && + !consentComplete && + !uspConsentCommitPending && + !managingPreferences && + getUspConsentControls() + ) { + showConsentNotification() + } + + if (uspConsentCommitPending && !document.getElementById('qc-cmp2-usp')) { + finishConsent() + } +} + function handleTcfConsentEvent(data: TcfData, success: boolean) { if (!success) return + detectConsentVariant() if (data.listenerId !== undefined) { tcfListenerId = data.listenerId @@ -258,11 +467,47 @@ function handleTcfConsentEvent(data: TcfData, success: boolean) { if (!managingPreferences) { showConsentNotification() } - } else if (data.eventStatus === 'useractioncomplete') { + } else if (data.eventStatus === 'useractioncomplete' && consentVariant === 'tcf') { finishConsent() } } +function handleGppConsentEvent(data: GppData, success: boolean) { + if (data.listenerId !== undefined) { + gppListenerId = data.listenerId + } + + if ( + success && + data.eventName === 'sectionChange' && + consentVariant === 'usp' && + uspConsentCommitPending + ) { + finishConsent() + } +} + +function installGppConsentListener() { + if (gppListenerInstalled) return + + const gppApi = getGppApi() + if (!gppApi) { + if (gppListenerAttempts < 60 && gppInstallTimeout === undefined) { + gppListenerAttempts += 1 + gppInstallTimeout = setTimeout(() => { + gppInstallTimeout = undefined + installGppConsentListener() + }, 500) + } + return + } + + gppListenerInstalled = true + clearTimeout(gppInstallTimeout) + gppInstallTimeout = undefined + gppApi('addEventListener', handleGppConsentEvent) +} + function installTcfConsentListener() { if (listenerInstalled) return @@ -273,31 +518,56 @@ function installTcfConsentListener() { tcfApi('addEventListener', 2, handleTcfConsentEvent) } -function handleDocumentClick(event: MouseEvent) { - if (!managingPreferences || !(event.target instanceof Element)) return - if (!event.target.closest('.qc-cmp2-close-icon')) return - +function restoreConsentNotification() { setTimeout(() => { - if (notificationId === null) return - managingPreferences = false - setConsentUiHidden(true) + showConsentNotification() }) } +function handleDocumentClick(event: MouseEvent) { + if (!(event.target instanceof Element)) return + + if (event.target.closest('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) { + beginUspConsentCommit() + return + } + + if (!managingPreferences) return + if (!event.target.closest('.qc-cmp2-close-icon, .qc-usp-close-icon')) return + + restoreConsentNotification() +} + +function handleDocumentKeydown(event: KeyboardEvent) { + if (event.key === 'Escape' && managingPreferences) { + restoreConsentNotification() + } +} + onMounted(() => { - consentContainerObserver = new MutationObserver(patchConsentFocusTrapContainer) - consentContainerObserver.observe(document.body, { childList: true, subtree: true }) - patchConsentFocusTrapContainer() + consentContainerObserver = new MutationObserver(syncConsentUi) + consentContainerObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['aria-label', 'disabled'], + }) + syncConsentUi() installTcfConsentListener() + installGppConsentListener() window.addEventListener('modrinth-cmp-ready', installTcfConsentListener) document.addEventListener('click', handleDocumentClick, true) + document.addEventListener('keydown', handleDocumentKeydown, true) }) onBeforeUnmount(() => { consentContainerObserver?.disconnect() + clearTimeout(uspCommitTimeout) + clearTimeout(gppInstallTimeout) window.removeEventListener('modrinth-cmp-ready', installTcfConsentListener) document.removeEventListener('click', handleDocumentClick, true) + document.removeEventListener('keydown', handleDocumentKeydown, true) setConsentUiHidden(false) for (const [container, originalContains] of consentContainerContains) { container.contains = originalContains @@ -309,6 +579,11 @@ onBeforeUnmount(() => { tcfApi('removeEventListener', 2, () => {}, tcfListenerId) } + const gppApi = getGppApi() + if (gppApi && gppListenerId !== undefined) { + gppApi('removeEventListener', () => {}, gppListenerId) + } + if (notificationId !== null) { notificationManager.removeNotification(notificationId) } @@ -319,7 +594,8 @@ onBeforeUnmount(() => { html.modrinth-cmp-summary-hidden .qc-cmp2-container, html.modrinth-cmp-summary-hidden #qc-cmp2-container, html.modrinth-cmp-summary-hidden #qc-cmp2-main, -html.modrinth-cmp-summary-hidden #qc-cmp2-ui { +html.modrinth-cmp-summary-hidden #qc-cmp2-ui, +html.modrinth-cmp-summary-hidden #qc-cmp2-usp { display: none !important; z-index: -1 !important; pointer-events: none !important; diff --git a/packages/ui/src/components/nav/NotificationPanel.vue b/packages/ui/src/components/nav/NotificationPanel.vue index 40ab711661..f0ea699665 100644 --- a/packages/ui/src/components/nav/NotificationPanel.vue +++ b/packages/ui/src/components/nav/NotificationPanel.vue @@ -17,7 +17,7 @@ @mouseleave="setNotificationTimer(item)" >