Files
modrinth/apps/app/src/api/ads-consent/controller.js
T
2026-07-23 21:23:38 -07:00

308 lines
7.8 KiB
JavaScript

const ACTION_TIMEOUT = 10_000
const LAYOUT_DELAY = 100
const SUBMISSION_TIMEOUT = 10_000
class AdsConsentController {
constructor() {
/** @type {AdsConsentState} */
this.state = new AdsConsentState()
/** @type {AdsConsentPhase | null} */
this.preSubmissionPhase = null
/** @type {ReturnType<typeof setTimeout> | null} */
this.submissionTimeout = null
}
/** @returns {void} */
syncConsentPopup() {
// The CMP root persists while its internal views change, so only its removal finishes
// a normal active flow. Submissions wait for the CMP event or the timeout fallback so
// the consent has time to persist before the ads webview is refreshed.
const cmpMain = document.getElementById('qc-cmp2-main')
if (!cmpMain) {
if (this.state.phase === 'idle') {
document.documentElement.classList.remove('modrinth-ads-consent-overlay')
} else if (this.state.phase !== 'submitting-consent' && this.state.phase !== 'finishing') {
this.finishConsentFlow()
}
return
}
if (this.state.phase !== 'idle') return
document.documentElement.classList.add('modrinth-ads-consent-overlay')
const variant = this.detectVariant()
if (!areConsentControlsPresent(variant)) return
this.state.setState('showing-popup')
this.setPopupMode('custom')
}
/**
* Sends the clicks from custom privacy popup to native popup buttons
* @param {ConsentAction} action
* @returns {Promise<void>}
*/
async performAction(action) {
if (!['accept', 'reject', 'manage'].includes(action)) return
if (action === 'manage') {
try {
await this.openPreferences()
} catch {
this.showNativeCmpFallback()
}
return
}
const variant = this.detectVariant()
if (!isConsentActionAvailable(action, variant)) {
this.showNativeCmpFallback()
return
}
try {
const result = await performDocumentConsentAction(action, variant, () =>
this.beginConsentSubmission(),
)
if (result !== 'handled') {
this.cancelConsentSubmission()
this.showNativeCmpFallback()
}
} catch {
this.cancelConsentSubmission()
this.showNativeCmpFallback()
}
}
/** @returns {void} */
beginConsentSubmission() {
if (
!['showing-popup', 'showing-preferences', 'showing-reopened-preferences'].includes(
this.state.phase,
)
) {
return
}
this.preSubmissionPhase = this.state.phase
this.state.setState('submitting-consent')
clearTimeout(this.submissionTimeout ?? undefined)
this.submissionTimeout = setTimeout(() => {
const preSubmissionPhase = this.preSubmissionPhase
this.submissionTimeout = null
const dialogId = this.state.variant === 'usp' ? 'qc-cmp2-usp' : 'qc-cmp2-ui'
if (this.state.phase === 'submitting-consent' && !document.getElementById(dialogId)) {
this.finishConsentFlow()
} else if (this.state.phase === 'submitting-consent' && preSubmissionPhase) {
this.state.setState(preSubmissionPhase)
this.preSubmissionPhase = null
this.showNativeCmpFallback()
}
}, SUBMISSION_TIMEOUT)
}
/** @returns {void} */
cancelConsentSubmission() {
clearTimeout(this.submissionTimeout ?? undefined)
this.submissionTimeout = null
if (this.state.phase === 'submitting-consent' && this.preSubmissionPhase) {
this.state.setState(this.preSubmissionPhase)
}
this.preSubmissionPhase = null
}
/**
* @param {{ eventStatus?: string } | null | undefined} tcData
* @param {boolean} success
* @returns {void}
*/
handleTcfConsentEvent(tcData, success) {
if (
success &&
tcData?.eventStatus === 'useractioncomplete' &&
this.state.variant === 'tcf' &&
this.state.phase !== 'idle' &&
this.state.phase !== 'finishing'
) {
this.finishConsentFlow()
}
}
/**
* @param {{ eventName?: string } | null | undefined} gppData
* @param {boolean} success
* @returns {void}
*/
handleGppConsentEvent(gppData, success) {
if (
success &&
gppData?.eventName === 'sectionChange' &&
this.state.variant === 'usp' &&
this.state.phase === 'submitting-consent'
) {
this.finishConsentFlow()
}
}
/** @returns {Promise<void>} */
async reopenPreferences() {
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) {
try {
await this.openPreferences()
} catch {
this.showNativeCmpFallback()
}
return
}
this.state.setState('showing-reopened-preferences')
this.preparePreferences()
try {
await this.waitForLayout()
await expandAdsConsentWebview()
await this.waitForLayout()
this.revealPreferences()
window.dispatchEvent(new Event('resize'))
if (!(await this.displayReopenedPopup())) {
this.finishReopenedPopup()
return
}
if (!(await this.openConsentManagerWhenReady(ACTION_TIMEOUT))) {
this.showNativeCmpFallback()
}
} catch {
this.finishReopenedPopup()
}
}
/** @returns {AdsConsentVariant | null} */
detectVariant() {
const variant = detectConsentVariant()
if (variant) this.state.setVariant(variant)
return variant
}
/**
* @param {number} timeoutMs
* @returns {Promise<boolean>}
*/
async openConsentManagerWhenReady(timeoutMs) {
const deadline = Date.now() + timeoutMs
while (true) {
try {
const result = await performDocumentConsentAction('manage', this.detectVariant())
if (result === 'handled') return true
if (result === 'failed' || Date.now() >= deadline) return false
} catch {
return false
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
/** @returns {void} */
preparePreferences() {
document.documentElement.classList.add('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
}
/** @returns {void} */
revealPreferences() {
document.documentElement.classList.add('modrinth-ads-consent-preferences-visible')
}
/** @returns {void} */
concealPreferences() {
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
}
/** @returns {Promise<void>} */
waitForLayout() {
return new Promise((resolve) => setTimeout(resolve, LAYOUT_DELAY))
}
/** @returns {Promise<void>} */
async showExpandedUi() {
this.preparePreferences()
await this.waitForLayout()
await expandAdsConsentWebview()
await this.waitForLayout()
this.revealPreferences()
window.dispatchEvent(new Event('resize'))
}
/** @returns {Promise<boolean>} */
async openPreferences() {
this.state.setState('showing-preferences')
await this.showExpandedUi()
if (!(await this.openConsentManagerWhenReady(ACTION_TIMEOUT))) {
this.showNativeCmpFallback()
return false
}
return true
}
/** @returns {Promise<boolean>} */
async displayReopenedPopup() {
if ((this.state.variant === 'usp' || (await isUspConsentApplicable())) && window.__uspapi) {
this.state.setVariant('usp')
return displayUspConsentUi()
}
if (!window.__tcfapi) return false
this.state.setVariant('tcf')
window.__tcfapi('displayConsentUi', 2, () => {})
return true
}
/**
* @param {AdsConsentPopupMode} mode
* @returns {void}
*/
setPopupMode(mode) {
const shown = mode !== 'hidden'
const hidden = mode === 'hidden'
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
document.documentElement.classList.toggle('modrinth-ads-consent-fallback', mode === 'fallback')
if (hidden) this.concealPreferences()
invokeAdsConsentPopupMode(mode)
}
/** @returns {void} */
showNativeCmpFallback() {
this.concealPreferences()
this.setPopupMode('fallback')
}
/** @returns {void} */
finishReopenedPopup() {
this.finishConsentFlow()
}
/** @returns {void} */
finishConsentFlow() {
if (this.state.phase === 'idle' || this.state.phase === 'finishing') {
return
}
clearTimeout(this.submissionTimeout ?? undefined)
this.submissionTimeout = null
this.preSubmissionPhase = null
this.state.setState('finishing')
this.setPopupMode('hidden')
}
}