mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
feat: implement custom notification and popup components (#6768)
* feat: implement custom popup * feat: add privacy setting * style fixes * more style fixes * feat: implement notification panel changes and better popup in website * feat: update popup button * fix: remove unnecessary modal props * fixes * pnpm prepr * remove devtools opening * update query to find buttons * update copy * update copy * fix: manage permissions depends on showAds booleans instead of managemetn availability
This commit is contained in:
+114
-21
@@ -89,7 +89,14 @@ import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { config } from '@/config'
|
||||
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
import {
|
||||
ads_consent_listener,
|
||||
get_ads_consent_required,
|
||||
hide_ads_window,
|
||||
init_ads_window,
|
||||
perform_ads_consent_action,
|
||||
show_ads_window,
|
||||
} from '@/helpers/ads.js'
|
||||
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable } from '@/helpers/auth.js'
|
||||
import { get_user, get_version } from '@/helpers/cache.js'
|
||||
@@ -184,6 +191,8 @@ const { handleError, addNotification } = notificationManager
|
||||
const popupNotificationManager = new AppPopupNotificationManager()
|
||||
providePopupNotificationManager(popupNotificationManager)
|
||||
const { addPopupNotification } = popupNotificationManager
|
||||
let adsConsentPopupId = null
|
||||
let unlistenAdsConsent
|
||||
|
||||
const appVersion = getVersion()
|
||||
const tauriApiClient = new TauriModrinthClient({
|
||||
@@ -213,9 +222,20 @@ const { data: authenticatedModrinthUser } = useQuery({
|
||||
enabled: () => !!credentials.value?.session,
|
||||
retry: false,
|
||||
})
|
||||
const hasPlus = computed(
|
||||
() =>
|
||||
!!credentials.value?.user &&
|
||||
(hasMidasBadge(credentials.value.user) ||
|
||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
)
|
||||
const showAd = computed(
|
||||
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
)
|
||||
const adConsentAvailable = computed(() => credentials.value !== undefined && !hasPlus.value)
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(true),
|
||||
showAds: ref(false),
|
||||
showAds: showAd,
|
||||
adConsentAvailable,
|
||||
floatingActionBarOffsets: {
|
||||
left: ref(APP_LEFT_NAV_WIDTH),
|
||||
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
|
||||
@@ -295,6 +315,12 @@ const authUnreachable = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await useCheckDisableMouseover()
|
||||
try {
|
||||
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
|
||||
handleAdsConsentRequired(await get_ads_consent_required())
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
document.querySelector('body').addEventListener('click', handleClick)
|
||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||
@@ -308,6 +334,7 @@ onUnmounted(async () => {
|
||||
unsubscribeSidebarToggle()
|
||||
clearDelayedUpdatePopup()
|
||||
|
||||
await unlistenAdsConsent?.()
|
||||
await unlistenUpdateDownload?.()
|
||||
})
|
||||
|
||||
@@ -332,8 +359,77 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'Minecraft authentication servers may be down right now. Check your internet connection and try again later.',
|
||||
},
|
||||
adsConsentTitle: {
|
||||
id: 'app.ads-consent.title',
|
||||
defaultMessage: 'Your privacy and how ads support Modrinth',
|
||||
},
|
||||
adsConsentBody: {
|
||||
id: 'app.ads-consent.body',
|
||||
defaultMessage:
|
||||
'Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance.',
|
||||
},
|
||||
adsConsentManage: {
|
||||
id: 'app.ads-consent.manage',
|
||||
defaultMessage: 'Manage preferences',
|
||||
},
|
||||
adsConsentReject: {
|
||||
id: 'app.ads-consent.reject',
|
||||
defaultMessage: 'Reject all',
|
||||
},
|
||||
adsConsentAccept: {
|
||||
id: 'app.ads-consent.accept',
|
||||
defaultMessage: 'Accept all',
|
||||
},
|
||||
})
|
||||
|
||||
function handleAdsConsentRequired(required) {
|
||||
if (!required) {
|
||||
if (adsConsentPopupId !== null) {
|
||||
popupNotificationManager.removeNotification(adsConsentPopupId)
|
||||
adsConsentPopupId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
adsConsentPopupId !== null &&
|
||||
popupNotificationManager.getNotifications().some((item) => item.id === adsConsentPopupId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const notification = addPopupNotification({
|
||||
title: formatMessage(messages.adsConsentTitle),
|
||||
text: formatMessage(messages.adsConsentBody),
|
||||
type: 'info',
|
||||
hideIcon: true,
|
||||
autoCloseMs: null,
|
||||
dismissible: false,
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(messages.adsConsentManage),
|
||||
action: () => perform_ads_consent_action('manage').catch(handleError),
|
||||
color: 'standard',
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.adsConsentReject),
|
||||
action: () => perform_ads_consent_action('reject').catch(handleError),
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.adsConsentAccept),
|
||||
action: () => perform_ads_consent_action('accept').catch(handleError),
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
adsConsentPopupId = notification.id
|
||||
}
|
||||
|
||||
async function setupApp() {
|
||||
const {
|
||||
native_decorations,
|
||||
@@ -403,7 +499,7 @@ async function setupApp() {
|
||||
addNotification({
|
||||
title: 'Warning',
|
||||
text: e.message,
|
||||
type: 'warn',
|
||||
type: 'warning',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -703,17 +799,6 @@ async function logOut() {
|
||||
await fetchCredentials()
|
||||
}
|
||||
|
||||
const hasPlus = computed(
|
||||
() =>
|
||||
!!credentials.value?.user &&
|
||||
(hasMidasBadge(credentials.value.user) ||
|
||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
)
|
||||
|
||||
const showAd = computed(
|
||||
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
)
|
||||
|
||||
async function fetchIntercomToken() {
|
||||
const creds = await getCreds()
|
||||
if (!creds?.session) {
|
||||
@@ -740,13 +825,21 @@ async function fetchIntercomToken() {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
watch(showAd, () => {
|
||||
if (!showAd.value) {
|
||||
hide_ads_window(true)
|
||||
} else {
|
||||
init_ads_window(true)
|
||||
}
|
||||
})
|
||||
watch(
|
||||
[showAd, adConsentAvailable],
|
||||
async ([showAds, canManageConsent]) => {
|
||||
if (showAds) {
|
||||
await init_ads_window(true)
|
||||
return
|
||||
}
|
||||
|
||||
await hide_ads_window(true)
|
||||
if (canManageConsent) {
|
||||
await init_ads_window()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
invoke('show_window')
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { Toggle } from '@modrinth/ui'
|
||||
import { Settings2Icon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPageContext,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { open_ads_consent_preferences } from '@/helpers/ads.js'
|
||||
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { adConsentAvailable } = injectPageContext()
|
||||
const settings = ref(await get())
|
||||
|
||||
const messages = defineMessages({
|
||||
adsConsentTitle: {
|
||||
id: 'app.ads-consent.title',
|
||||
defaultMessage: 'Your privacy and how ads support Modrinth',
|
||||
},
|
||||
adsConsentIntro: {
|
||||
id: 'app.settings.privacy.ads-consent.intro',
|
||||
defaultMessage:
|
||||
'Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance. You can opt out or manage your preferences below.',
|
||||
},
|
||||
adsConsentManage: {
|
||||
id: 'app.ads-consent.manage',
|
||||
defaultMessage: 'Manage preferences',
|
||||
},
|
||||
})
|
||||
|
||||
async function manageAdsPreferences() {
|
||||
await open_ads_consent_preferences().catch(handleError)
|
||||
}
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
@@ -23,18 +55,26 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Personalized ads</h2>
|
||||
<p class="m-0 mt-1 text-sm">
|
||||
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
|
||||
option, you opt out and ads will no longer be shown based on your interests.
|
||||
</p>
|
||||
<div v-if="adConsentAvailable">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.adsConsentTitle) }}
|
||||
</h2>
|
||||
<div class="mt-1 flex flex-col gap-2.5 items-start">
|
||||
<div class="flex flex-col gap-1 items-start">
|
||||
<div class="text-sm">
|
||||
{{ formatMessage(messages.adsConsentIntro) }}
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button class="!shadow-none" @click="manageAdsPreferences">
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
{{ formatMessage(messages.adsConsentManage) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-4">
|
||||
<div class="mt-8 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
|
||||
<p class="m-0 mt-1 text-sm">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
|
||||
export async function init_ads_window(overrideShown = false) {
|
||||
return await invoke('plugin:ads|init_ads_window', {
|
||||
@@ -15,6 +16,22 @@ export async function hide_ads_window(reset) {
|
||||
return await invoke('plugin:ads|hide_ads_window', { reset })
|
||||
}
|
||||
|
||||
export async function get_ads_consent_required() {
|
||||
return await invoke('plugin:ads|get_ads_consent_required')
|
||||
}
|
||||
|
||||
export async function perform_ads_consent_action(action) {
|
||||
return await invoke('plugin:ads|perform_ads_consent_action', { action })
|
||||
}
|
||||
|
||||
export async function open_ads_consent_preferences() {
|
||||
return await invoke('plugin:ads|open_ads_consent_preferences')
|
||||
}
|
||||
|
||||
export async function ads_consent_listener(callback) {
|
||||
return await listen('ads-consent-required', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
export async function record_ads_click() {
|
||||
return await invoke('plugin:ads|record_ads_click')
|
||||
}
|
||||
|
||||
@@ -125,6 +125,21 @@
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "View logs"
|
||||
},
|
||||
"app.ads-consent.accept": {
|
||||
"message": "Accept all"
|
||||
},
|
||||
"app.ads-consent.body": {
|
||||
"message": "Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance."
|
||||
},
|
||||
"app.ads-consent.manage": {
|
||||
"message": "Manage preferences"
|
||||
},
|
||||
"app.ads-consent.reject": {
|
||||
"message": "Reject all"
|
||||
},
|
||||
"app.ads-consent.title": {
|
||||
"message": "Your privacy and how ads support Modrinth"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
|
||||
},
|
||||
@@ -500,6 +515,9 @@
|
||||
"app.settings.downloading": {
|
||||
"message": "Downloading v{version}"
|
||||
},
|
||||
"app.settings.privacy.ads-consent.intro": {
|
||||
"message": "Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance. You can opt out or manage your preferences below."
|
||||
},
|
||||
"app.settings.tabs.appearance": {
|
||||
"message": "Appearance"
|
||||
},
|
||||
|
||||
@@ -279,7 +279,12 @@ fn main() {
|
||||
"scroll_ads_window",
|
||||
"show_ads_window",
|
||||
"show_ads_consent_overlay",
|
||||
"show_ads_consent_preferences",
|
||||
"open_ads_consent_preferences",
|
||||
"hide_ads_consent_preferences",
|
||||
"hide_ads_consent_overlay",
|
||||
"get_ads_consent_required",
|
||||
"perform_ads_consent_action",
|
||||
"record_ads_click",
|
||||
"open_link",
|
||||
"get_ads_personalization",
|
||||
|
||||
@@ -6,6 +6,14 @@ document.addEventListener(
|
||||
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
|
||||
|
||||
let target = e.target
|
||||
if (target?.closest?.('.qc-cmp2-close-icon')) {
|
||||
if (modrinthAdsConsentReprompt) {
|
||||
setTimeout(finishAdsConsentReprompt)
|
||||
} else if (document.documentElement.classList.contains('modrinth-ads-consent-preferences')) {
|
||||
setTimeout(() => void restoreAdsConsentNotification())
|
||||
}
|
||||
}
|
||||
|
||||
while (target != null) {
|
||||
if (target.matches('a')) {
|
||||
e.preventDefault()
|
||||
@@ -27,6 +35,25 @@ window.open = (url, target, features) => {
|
||||
let modrinthAdsConsentOverlayShown = false
|
||||
let modrinthTcfListenerInstalled = false
|
||||
let modrinthTcfListenerAttempts = 0
|
||||
let modrinthAdsConsentReprompt = false
|
||||
let modrinthAdsConsentRepromptManaging = false
|
||||
let modrinthAdsConsentActionRequestId = 0
|
||||
const modrinthAdsConsentActionResolvers = new Map()
|
||||
|
||||
function installAdsRailStyle() {
|
||||
if (document.getElementById('modrinth-ads-rail-style')) {
|
||||
return
|
||||
}
|
||||
const style = document.createElement('style')
|
||||
style.id = 'modrinth-ads-rail-style'
|
||||
style.textContent = `
|
||||
html.modrinth-ads-consent-preferences #modrinth-rail-1 {
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`
|
||||
document.documentElement.appendChild(style)
|
||||
}
|
||||
|
||||
function installAdsConsentOverlayStyle() {
|
||||
if (document.getElementById('modrinth-ads-consent-overlay-style')) {
|
||||
@@ -35,10 +62,16 @@ function installAdsConsentOverlayStyle() {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'modrinth-ads-consent-overlay-style'
|
||||
style.textContent = `
|
||||
html.modrinth-ads-consent-overlay #modrinth-rail-1 {
|
||||
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #modrinth-rail-1 {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-container,
|
||||
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-main {
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.qc-cmp2-close-icon {
|
||||
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath d='M.5.5l23 23m0-23l-23 23' fill='none' stroke='%23b0bac5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E") 0% 0% / 66% auto no-repeat !important;
|
||||
}
|
||||
@@ -63,12 +96,243 @@ function invokeAdsConsentOverlayCommand(shown) {
|
||||
invoke(`plugin:ads|${command}`, args).catch(() => {})
|
||||
}
|
||||
|
||||
function revealAdsConsentPreferences() {
|
||||
document.documentElement.classList.add('modrinth-ads-consent-preferences')
|
||||
document.getElementById('modrinth-ads-consent-overlay-style')?.remove()
|
||||
}
|
||||
|
||||
function concealAdsConsentPreferences() {
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
|
||||
installAdsConsentOverlayStyle()
|
||||
}
|
||||
|
||||
async function expandAdsConsentWebview() {
|
||||
const invoke = getTauriInvoke()
|
||||
if (typeof invoke !== 'function') {
|
||||
throw new Error('Tauri invoke is unavailable in the ads webview')
|
||||
}
|
||||
|
||||
await invoke('plugin:ads|show_ads_consent_preferences')
|
||||
}
|
||||
|
||||
function sendAdsConsentCommandToChildFrames(command) {
|
||||
document.querySelectorAll('iframe').forEach((frame) => {
|
||||
frame.contentWindow?.postMessage({ modrinthAdsConsentCommand: command }, '*')
|
||||
})
|
||||
}
|
||||
|
||||
function isDirectChildFrame(source) {
|
||||
return Array.from(document.querySelectorAll('iframe')).some(
|
||||
(frame) => frame.contentWindow === source,
|
||||
)
|
||||
}
|
||||
|
||||
function findAdsConsentButton(action) {
|
||||
const container = document.querySelector('#qc-cmp2-container, #qc-cmp2-main')
|
||||
if (!container) return null
|
||||
const summaryButtons = Array.from(container.querySelectorAll('.qc-cmp2-summary-buttons button'))
|
||||
|
||||
if (action === 'accept') {
|
||||
const explicitAcceptButton = container.querySelector('#accept-btn')
|
||||
if (explicitAcceptButton) return explicitAcceptButton
|
||||
if (summaryButtons.length >= 3) return summaryButtons[2]
|
||||
|
||||
return container.querySelector('.qc-cmp2-summary-buttons button[mode="primary"]')
|
||||
}
|
||||
|
||||
if (action === 'reject') {
|
||||
const explicitRejectButton = container.querySelector('#disagree-btn')
|
||||
if (explicitRejectButton) return explicitRejectButton
|
||||
if (summaryButtons.length >= 3) return summaryButtons[1]
|
||||
|
||||
const secondaryButtons = container.querySelectorAll(
|
||||
'.qc-cmp2-summary-buttons button[mode="secondary"]',
|
||||
)
|
||||
if (secondaryButtons.length > 1) return secondaryButtons[1]
|
||||
|
||||
return summaryButtons.find(
|
||||
(button) => button.textContent?.trim().toLowerCase() === 'reject all',
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
container.querySelector(
|
||||
'#more-options-btn, .qc-cmp2-summary-buttons > button[mode="secondary"]:first-of-type',
|
||||
) ?? summaryButtons[0]
|
||||
)
|
||||
}
|
||||
|
||||
function clickAdsConsentButtonWhenReady(action, timeoutMs, onButtonFound) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function tryClick() {
|
||||
const button = findAdsConsentButton(action)
|
||||
if (button) {
|
||||
// CMP navigation can replace this document during the click, so acknowledge it first.
|
||||
onButtonFound?.()
|
||||
resolve(true)
|
||||
button.click()
|
||||
} else if (Date.now() >= deadline) {
|
||||
resolve(false)
|
||||
} else {
|
||||
setTimeout(tryClick, 50)
|
||||
}
|
||||
}
|
||||
|
||||
tryClick()
|
||||
})
|
||||
}
|
||||
|
||||
function performAdsConsentActionAcrossFrames(action, timeoutMs) {
|
||||
const requestId = `${Date.now()}-${++modrinthAdsConsentActionRequestId}`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const settle = (clicked) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
modrinthAdsConsentActionResolvers.delete(requestId)
|
||||
resolve(clicked)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => settle(false), timeoutMs)
|
||||
modrinthAdsConsentActionResolvers.set(requestId, () => settle(true))
|
||||
sendAdsConsentCommandToChildFrames({ type: 'perform', action, requestId, timeoutMs })
|
||||
clickAdsConsentButtonWhenReady(action, timeoutMs, () => settle(true)).then((clicked) => {
|
||||
if (!clicked) settle(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function waitForAdsConsentLayout() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
async function restoreAdsConsentNotification() {
|
||||
concealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'conceal' })
|
||||
|
||||
const invoke = getTauriInvoke()
|
||||
if (typeof invoke === 'function') {
|
||||
await invoke('plugin:ads|hide_ads_consent_preferences')
|
||||
}
|
||||
}
|
||||
|
||||
function finishAdsConsentReprompt() {
|
||||
modrinthAdsConsentReprompt = false
|
||||
modrinthAdsConsentRepromptManaging = false
|
||||
modrinthAdsConsentOverlayShown = false
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-overlay')
|
||||
concealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'conceal' })
|
||||
invokeAdsConsentOverlayCommand(false)
|
||||
}
|
||||
|
||||
async function openAdsConsentPreferences() {
|
||||
revealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
|
||||
await expandAdsConsentWebview()
|
||||
await waitForAdsConsentLayout()
|
||||
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
sendAdsConsentCommandToChildFrames({ type: 'resize' })
|
||||
|
||||
const clicked = await performAdsConsentActionAcrossFrames('manage', 2500)
|
||||
if (!clicked) {
|
||||
await restoreAdsConsentNotification()
|
||||
}
|
||||
}
|
||||
|
||||
async function performAdsConsentAction(action) {
|
||||
if (!['accept', 'reject', 'manage'].includes(action)) return
|
||||
|
||||
if (action === 'manage') {
|
||||
try {
|
||||
await openAdsConsentPreferences()
|
||||
} catch {
|
||||
await restoreAdsConsentNotification()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await performAdsConsentActionAcrossFrames(action, 1000)
|
||||
}
|
||||
|
||||
window.modrinthAdsConsentAction = (action) => {
|
||||
void performAdsConsentAction(action)
|
||||
}
|
||||
|
||||
window.modrinthAdsReopenConsentPreferences = async () => {
|
||||
modrinthAdsConsentReprompt = true
|
||||
modrinthAdsConsentRepromptManaging = false
|
||||
revealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
|
||||
|
||||
try {
|
||||
await expandAdsConsentWebview()
|
||||
await waitForAdsConsentLayout()
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
sendAdsConsentCommandToChildFrames({ type: 'resize' })
|
||||
|
||||
if (typeof window.__tcfapi === 'function') {
|
||||
window.__tcfapi('displayConsentUi', 2, () => {})
|
||||
} else {
|
||||
finishAdsConsentReprompt()
|
||||
}
|
||||
} catch {
|
||||
finishAdsConsentReprompt()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const resultRequestId = event.data?.modrinthAdsConsentResult
|
||||
if (typeof resultRequestId === 'string' && isDirectChildFrame(event.source)) {
|
||||
if (window.top === window) {
|
||||
modrinthAdsConsentActionResolvers.get(resultRequestId)?.()
|
||||
} else {
|
||||
window.parent.postMessage({ modrinthAdsConsentResult: resultRequestId }, '*')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (window.top === window || event.source !== window.parent) return
|
||||
|
||||
const command = event.data?.modrinthAdsConsentCommand
|
||||
if (!command || typeof command !== 'object') return
|
||||
|
||||
if (command.type === 'reveal') {
|
||||
revealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
} else if (command.type === 'conceal') {
|
||||
concealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
} else if (command.type === 'resize') {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
} else if (
|
||||
command.type === 'perform' &&
|
||||
typeof command.action === 'string' &&
|
||||
typeof command.requestId === 'string' &&
|
||||
typeof command.timeoutMs === 'number'
|
||||
) {
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
clickAdsConsentButtonWhenReady(command.action, command.timeoutMs, () => {
|
||||
window.parent.postMessage({ modrinthAdsConsentResult: command.requestId }, '*')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function setAdsConsentOverlay(shown) {
|
||||
if (modrinthAdsConsentOverlayShown === shown) return
|
||||
|
||||
modrinthAdsConsentOverlayShown = shown
|
||||
installAdsConsentOverlayStyle()
|
||||
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
|
||||
if (!shown) {
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
|
||||
}
|
||||
|
||||
if (window.top === window) {
|
||||
invokeAdsConsentOverlayCommand(shown)
|
||||
@@ -92,8 +356,23 @@ function handleTcfConsentEvent(tcData, success) {
|
||||
if (!success || !tcData) return
|
||||
|
||||
if (tcData.eventStatus === 'cmpuishown') {
|
||||
if (modrinthAdsConsentReprompt) {
|
||||
if (!modrinthAdsConsentRepromptManaging) {
|
||||
modrinthAdsConsentRepromptManaging = true
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
sendAdsConsentCommandToChildFrames({ type: 'resize' })
|
||||
void performAdsConsentActionAcrossFrames('manage', 2500)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setAdsConsentOverlay(true)
|
||||
} else if (tcData.eventStatus === 'useractioncomplete' || tcData.eventStatus === 'tcloaded') {
|
||||
} else if (tcData.eventStatus === 'useractioncomplete') {
|
||||
if (modrinthAdsConsentReprompt) {
|
||||
finishAdsConsentReprompt()
|
||||
return
|
||||
}
|
||||
|
||||
setAdsConsentOverlay(false)
|
||||
}
|
||||
}
|
||||
@@ -190,6 +469,7 @@ function muteVideos() {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
installAdsRailStyle()
|
||||
installAdsConsentOverlayStyle()
|
||||
muteVideos()
|
||||
muteAudioContext()
|
||||
|
||||
+170
-4
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Manager, PhysicalPosition, PhysicalSize, Runtime};
|
||||
use tauri::{Emitter, Manager, PhysicalPosition, PhysicalSize, Rect, Runtime};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use theseus::settings;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -11,6 +11,7 @@ use tokio::sync::RwLock;
|
||||
pub struct AdsState {
|
||||
pub shown: bool,
|
||||
pub modal_shown: bool,
|
||||
pub consent_required: bool,
|
||||
pub consent_overlay_shown: bool,
|
||||
pub occluded: bool,
|
||||
pub last_click: Option<Instant>,
|
||||
@@ -18,6 +19,7 @@ pub struct AdsState {
|
||||
}
|
||||
|
||||
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
|
||||
const ADS_CONSENT_REQUIRED_EVENT: &str = "ads-consent-required";
|
||||
const APP_TITLE_BAR_HEIGHT: f32 = 48.0;
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
|
||||
@@ -275,6 +277,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
app.manage(RwLock::new(AdsState {
|
||||
shown: true,
|
||||
modal_shown: false,
|
||||
consent_required: false,
|
||||
consent_overlay_shown: false,
|
||||
occluded: false,
|
||||
last_click: None,
|
||||
@@ -293,6 +296,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
.map(|state| {
|
||||
state.shown
|
||||
&& !state.modal_shown
|
||||
&& !state.consent_required
|
||||
&& !state.consent_overlay_shown
|
||||
&& !state.occluded
|
||||
})
|
||||
@@ -358,7 +362,12 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
hide_ads_window,
|
||||
show_ads_window,
|
||||
show_ads_consent_overlay,
|
||||
show_ads_consent_preferences,
|
||||
open_ads_consent_preferences,
|
||||
hide_ads_consent_preferences,
|
||||
hide_ads_consent_overlay,
|
||||
get_ads_consent_required,
|
||||
perform_ads_consent_action,
|
||||
record_ads_click,
|
||||
open_link,
|
||||
get_ads_personalization,
|
||||
@@ -635,6 +644,10 @@ pub async fn init_ads_window<R: Runtime>(
|
||||
// });
|
||||
}
|
||||
|
||||
if state.shown && state.consent_required {
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -648,6 +661,8 @@ pub async fn show_ads_window<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
dpr: f32,
|
||||
) -> crate::api::Result<()> {
|
||||
let mut consent_required = false;
|
||||
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
@@ -667,6 +682,12 @@ pub async fn show_ads_window<R: Runtime>(
|
||||
webview.show().ok();
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
}
|
||||
|
||||
consent_required = state.shown && state.consent_required;
|
||||
}
|
||||
|
||||
if consent_required {
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -677,11 +698,13 @@ pub async fn hide_ads_window<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
reset: Option<bool>,
|
||||
) -> crate::api::Result<()> {
|
||||
let reset = reset.unwrap_or(false);
|
||||
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
if reset.unwrap_or(false) {
|
||||
if reset {
|
||||
state.shown = false;
|
||||
state.consent_overlay_shown = false;
|
||||
} else {
|
||||
@@ -706,6 +729,10 @@ pub async fn hide_ads_window<R: Runtime>(
|
||||
webview.hide().ok();
|
||||
}
|
||||
|
||||
if reset {
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -722,11 +749,45 @@ pub async fn show_ads_consent_overlay<R: Runtime>(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state.consent_required = true;
|
||||
state.consent_overlay_shown = false;
|
||||
|
||||
if !state.modal_shown {
|
||||
let dpr = get_device_pixel_ratio(&app, None);
|
||||
let (position, size) = get_webview_position(&app, dpr)?;
|
||||
webview.set_size(size).ok();
|
||||
webview.set_position(position).ok();
|
||||
webview.show().ok();
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
}
|
||||
}
|
||||
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_ads_consent_preferences<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<()> {
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
if !state.consent_required {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state.consent_overlay_shown = true;
|
||||
|
||||
let (position, size) = get_overlay_webview_position(&app)?;
|
||||
webview.set_size(size).ok();
|
||||
webview.set_position(position).ok();
|
||||
webview
|
||||
.set_bounds(Rect {
|
||||
position: position.into(),
|
||||
size: size.into(),
|
||||
})
|
||||
.ok();
|
||||
webview.show().ok();
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
}
|
||||
@@ -734,6 +795,62 @@ pub async fn show_ads_consent_overlay<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_ads_consent_preferences<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<()> {
|
||||
let Some(webview) = app.webviews().get("ads-window").cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
{
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
state.consent_required = true;
|
||||
state.consent_overlay_shown = false;
|
||||
}
|
||||
|
||||
webview.eval("window.modrinthAdsReopenConsentPreferences?.()")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restores the ad inventory bounds without resolving the pending consent request.
|
||||
#[tauri::command]
|
||||
pub async fn hide_ads_consent_preferences<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<()> {
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
state.consent_overlay_shown = false;
|
||||
|
||||
if state.shown && !state.modal_shown {
|
||||
let dpr = get_device_pixel_ratio(&app, None);
|
||||
let (position, size) = get_webview_position(&app, dpr)?;
|
||||
|
||||
webview
|
||||
.set_bounds(Rect {
|
||||
position: position.into(),
|
||||
size: size.into(),
|
||||
})
|
||||
.ok();
|
||||
webview.show().ok();
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
} else {
|
||||
webview
|
||||
.set_position(PhysicalPosition::new(-1000, -1000))
|
||||
.ok();
|
||||
webview.hide().ok();
|
||||
}
|
||||
}
|
||||
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hide_ads_consent_overlay<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
@@ -742,7 +859,9 @@ pub async fn hide_ads_consent_overlay<R: Runtime>(
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
let should_reload_ads = state.consent_required;
|
||||
|
||||
state.consent_required = false;
|
||||
state.consent_overlay_shown = false;
|
||||
|
||||
if state.shown && !state.modal_shown {
|
||||
@@ -759,6 +878,53 @@ pub async fn hide_ads_consent_overlay<R: Runtime>(
|
||||
.ok();
|
||||
webview.hide().ok();
|
||||
}
|
||||
|
||||
drop(state);
|
||||
|
||||
if should_reload_ads {
|
||||
webview.navigate(AD_LINK.parse().unwrap()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_ads_consent_required<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<bool> {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let state = state.read().await;
|
||||
|
||||
Ok(state.shown && state.consent_required)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn perform_ads_consent_action<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
action: String,
|
||||
) -> crate::api::Result<()> {
|
||||
let script = match action.as_str() {
|
||||
"accept" => "window.modrinthAdsConsentAction?.('accept')",
|
||||
"reject" => "window.modrinthAdsConsentAction?.('reject')",
|
||||
"manage" => "window.modrinthAdsConsentAction?.('manage')",
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let should_perform = {
|
||||
let state = state.read().await;
|
||||
state.shown && state.consent_required
|
||||
};
|
||||
|
||||
if !should_perform {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
webview.eval(script)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<LoadingBar />
|
||||
</ClientOnly>
|
||||
<NotificationPanel />
|
||||
<AdsConsentNotification />
|
||||
<I18nDebugPanel />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
@@ -12,6 +13,7 @@
|
||||
<script setup lang="ts">
|
||||
import { I18nDebugPanel, LoadingBar, NotificationPanel } from '@modrinth/ui'
|
||||
|
||||
import AdsConsentNotification from '~/components/ui/AdsConsentNotification.vue'
|
||||
import { setupProviders } from '~/providers/setup.ts'
|
||||
|
||||
import { useAuth } from './composables/auth'
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
type WebNotification,
|
||||
} from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
type ConsentAction = 'accept' | 'reject' | 'manage'
|
||||
|
||||
interface TcfData {
|
||||
eventStatus?: string
|
||||
listenerId?: number
|
||||
}
|
||||
|
||||
type TcfCallback = (data: TcfData, success: boolean) => void
|
||||
type TcfApi = (command: string, version: number, callback: TcfCallback, parameter?: unknown) => void
|
||||
|
||||
const CMP_HIDDEN_CLASS = 'modrinth-cmp-summary-hidden'
|
||||
const notificationManager = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'ads-consent.title',
|
||||
defaultMessage: 'Your privacy and how ads support Modrinth',
|
||||
},
|
||||
body: {
|
||||
id: 'ads-consent.body',
|
||||
defaultMessage:
|
||||
'Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies on the website to personalize ads and measure performance.',
|
||||
},
|
||||
manage: {
|
||||
id: 'ads-consent.manage',
|
||||
defaultMessage: 'Manage preferences',
|
||||
},
|
||||
reject: {
|
||||
id: 'ads-consent.reject',
|
||||
defaultMessage: 'Reject all',
|
||||
},
|
||||
accept: {
|
||||
id: 'ads-consent.accept',
|
||||
defaultMessage: 'Accept all',
|
||||
},
|
||||
})
|
||||
|
||||
let notificationId: WebNotification['id'] | null = null
|
||||
let tcfListenerId: number | undefined
|
||||
let listenerInstalled = false
|
||||
let managingPreferences = false
|
||||
let consentContainerObserver: MutationObserver | undefined
|
||||
const consentContainerContains = new Map<HTMLElement, HTMLElement['contains']>()
|
||||
|
||||
function getTcfApi(): TcfApi | undefined {
|
||||
return (window as typeof window & { __tcfapi?: TcfApi }).__tcfapi
|
||||
}
|
||||
|
||||
function setConsentUiHidden(hidden: boolean) {
|
||||
document.documentElement.classList.toggle(CMP_HIDDEN_CLASS, hidden)
|
||||
patchConsentFocusTrapContainer()
|
||||
}
|
||||
|
||||
function patchConsentFocusTrapContainer() {
|
||||
const container = document.querySelector<HTMLElement>('#qc-cmp2-ui')
|
||||
if (!container || consentContainerContains.has(container)) return
|
||||
|
||||
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 getConsentContainers(): ParentNode[] {
|
||||
const containers = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('#qc-cmp2-container, #qc-cmp2-main, #qc-cmp2-ui'),
|
||||
)
|
||||
|
||||
return containers.length > 0 ? containers : [document]
|
||||
}
|
||||
|
||||
function matchesButtonText(button: HTMLButtonElement, terms: string[]): boolean {
|
||||
const text = [button.textContent, button.getAttribute('aria-label')]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
return terms.some((term) => text.includes(term))
|
||||
}
|
||||
|
||||
function findConsentButton(action: ConsentAction): HTMLButtonElement | null {
|
||||
const containers = getConsentContainers()
|
||||
const summaryButtons = containers.flatMap((container) =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('.qc-cmp2-summary-buttons button')),
|
||||
)
|
||||
const queryButton = (selector: string) =>
|
||||
containers
|
||||
.map((container) => container.querySelector<HTMLButtonElement>(selector))
|
||||
.find((button) => button && !button.disabled) ?? null
|
||||
|
||||
if (action === 'accept') {
|
||||
const explicitAcceptButton = queryButton(
|
||||
'[data-testid="accept-all"], [data-testid="agree-button"], #accept-btn',
|
||||
)
|
||||
if (explicitAcceptButton) return explicitAcceptButton
|
||||
|
||||
const textMatch = summaryButtons.find((button) =>
|
||||
matchesButtonText(button, ['accept all', 'agree to all', 'allow all']),
|
||||
)
|
||||
if (textMatch) return textMatch
|
||||
if (summaryButtons.length >= 3 && !summaryButtons[2].disabled) return summaryButtons[2]
|
||||
|
||||
return queryButton('.qc-cmp2-summary-buttons button[mode="primary"]')
|
||||
}
|
||||
|
||||
if (action === 'reject') {
|
||||
const explicitRejectButton = queryButton(
|
||||
'[data-testid="reject-all"], [data-testid="disagree-button"], #disagree-btn, #reject-btn',
|
||||
)
|
||||
if (explicitRejectButton) return explicitRejectButton
|
||||
|
||||
const textMatch = summaryButtons.find((button) =>
|
||||
matchesButtonText(button, ['reject all', 'disagree', 'deny all']),
|
||||
)
|
||||
if (textMatch) return textMatch
|
||||
if (summaryButtons.length >= 3 && !summaryButtons[1].disabled) return summaryButtons[1]
|
||||
|
||||
const secondaryButtons = containers.flatMap((container) =>
|
||||
Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'.qc-cmp2-summary-buttons button[mode="secondary"]',
|
||||
),
|
||||
),
|
||||
)
|
||||
if (secondaryButtons.length > 1 && !secondaryButtons[1].disabled) return secondaryButtons[1]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
queryButton(
|
||||
'[data-testid="manage-preferences"], [data-testid="show-options"], .qc-cmp2-summary-buttons > button[mode="secondary"]:first-of-type',
|
||||
) ??
|
||||
summaryButtons.find((button) =>
|
||||
matchesButtonText(button, ['manage', 'preference', 'settings', 'options']),
|
||||
) ??
|
||||
summaryButtons.find((button) => !button.disabled) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function clickConsentButtonWhenReady(action: ConsentAction, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function tryClick() {
|
||||
const button = findConsentButton(action)
|
||||
if (button) {
|
||||
button.click()
|
||||
resolve(true)
|
||||
} else if (Date.now() >= deadline) {
|
||||
resolve(false)
|
||||
} else {
|
||||
setTimeout(tryClick, 50)
|
||||
}
|
||||
}
|
||||
|
||||
tryClick()
|
||||
})
|
||||
}
|
||||
|
||||
async function performConsentAction(action: ConsentAction) {
|
||||
if (action === 'manage') {
|
||||
managingPreferences = true
|
||||
setConsentUiHidden(false)
|
||||
}
|
||||
|
||||
const clicked = await clickConsentButtonWhenReady(action, action === 'manage' ? 2500 : 1000)
|
||||
|
||||
if (clicked) return
|
||||
|
||||
managingPreferences = action === 'manage'
|
||||
setConsentUiHidden(false)
|
||||
|
||||
if (action === 'manage') {
|
||||
const tcfApi = getTcfApi()
|
||||
if (tcfApi) {
|
||||
tcfApi('displayConsentUi', 2, () => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showConsentNotification() {
|
||||
setConsentUiHidden(true)
|
||||
|
||||
if (
|
||||
notificationId !== null &&
|
||||
notificationManager
|
||||
.getNotifications()
|
||||
.some((notification) => notification.id === notificationId)
|
||||
)
|
||||
return
|
||||
|
||||
const notification = notificationManager.addNotification({
|
||||
title: formatMessage(messages.title),
|
||||
text: formatMessage(messages.body),
|
||||
type: 'neutral',
|
||||
autoCloseMs: null,
|
||||
dismissible: false,
|
||||
copyable: false,
|
||||
noIcon: true,
|
||||
containerClass: 'py-2 !rounded-2xl',
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(messages.manage),
|
||||
action: () => performConsentAction('manage'),
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.reject),
|
||||
action: () => performConsentAction('reject'),
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.accept),
|
||||
action: () => performConsentAction('accept'),
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
notificationId = notification.id
|
||||
}
|
||||
|
||||
function finishConsent() {
|
||||
managingPreferences = false
|
||||
setConsentUiHidden(false)
|
||||
|
||||
if (notificationId !== null) {
|
||||
notificationManager.removeNotification(notificationId)
|
||||
notificationId = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleTcfConsentEvent(data: TcfData, success: boolean) {
|
||||
if (!success) return
|
||||
|
||||
if (data.listenerId !== undefined) {
|
||||
tcfListenerId = data.listenerId
|
||||
}
|
||||
|
||||
if (data.eventStatus === 'cmpuishown') {
|
||||
if (!managingPreferences) {
|
||||
showConsentNotification()
|
||||
}
|
||||
} else if (data.eventStatus === 'useractioncomplete') {
|
||||
finishConsent()
|
||||
}
|
||||
}
|
||||
|
||||
function installTcfConsentListener() {
|
||||
if (listenerInstalled) return
|
||||
|
||||
const tcfApi = getTcfApi()
|
||||
if (!tcfApi) return
|
||||
|
||||
listenerInstalled = true
|
||||
tcfApi('addEventListener', 2, handleTcfConsentEvent)
|
||||
}
|
||||
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!managingPreferences || !(event.target instanceof Element)) return
|
||||
if (!event.target.closest('.qc-cmp2-close-icon')) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (notificationId === null) return
|
||||
|
||||
managingPreferences = false
|
||||
setConsentUiHidden(true)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
consentContainerObserver = new MutationObserver(patchConsentFocusTrapContainer)
|
||||
consentContainerObserver.observe(document.body, { childList: true, subtree: true })
|
||||
patchConsentFocusTrapContainer()
|
||||
installTcfConsentListener()
|
||||
window.addEventListener('modrinth-cmp-ready', installTcfConsentListener)
|
||||
document.addEventListener('click', handleDocumentClick, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
consentContainerObserver?.disconnect()
|
||||
window.removeEventListener('modrinth-cmp-ready', installTcfConsentListener)
|
||||
document.removeEventListener('click', handleDocumentClick, true)
|
||||
setConsentUiHidden(false)
|
||||
for (const [container, originalContains] of consentContainerContains) {
|
||||
container.contains = originalContains
|
||||
}
|
||||
consentContainerContains.clear()
|
||||
|
||||
const tcfApi = getTcfApi()
|
||||
if (tcfApi && tcfListenerId !== undefined) {
|
||||
tcfApi('removeEventListener', 2, () => {}, tcfListenerId)
|
||||
}
|
||||
|
||||
if (notificationId !== null) {
|
||||
notificationManager.removeNotification(notificationId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
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 {
|
||||
display: none !important;
|
||||
z-index: -1 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -129,6 +129,7 @@ provideModrinthClient(client)
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(false),
|
||||
showAds: ref(false),
|
||||
adConsentAvailable: ref(false),
|
||||
openExternalUrl: (url) => window.open(url, '_blank'),
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
"admin.billing.error.not-found": {
|
||||
"message": "User not found"
|
||||
},
|
||||
"ads-consent.accept": {
|
||||
"message": "Accept all"
|
||||
},
|
||||
"ads-consent.body": {
|
||||
"message": "Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies on the website to personalize ads and measure performance."
|
||||
},
|
||||
"ads-consent.manage": {
|
||||
"message": "Manage preferences"
|
||||
},
|
||||
"ads-consent.reject": {
|
||||
"message": "Reject all"
|
||||
},
|
||||
"ads-consent.title": {
|
||||
"message": "Your privacy and how ads support Modrinth"
|
||||
},
|
||||
"analytics.action.add": {
|
||||
"message": "Add"
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ export function setupPageContextProvider() {
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(false),
|
||||
showAds: ref(false),
|
||||
adConsentAvailable: ref(false),
|
||||
featureFlags: {
|
||||
serverRamAsBytesAlwaysOn: computed(() => featureFlags.value.serverRamAsBytesAlwaysOn),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
;(function () {
|
||||
document.documentElement.classList.add('modrinth-cmp-summary-hidden')
|
||||
|
||||
var host = 'modrinth.com'
|
||||
// var host = window.location.hostname;
|
||||
var element = document.createElement('script')
|
||||
@@ -132,6 +134,7 @@
|
||||
}
|
||||
|
||||
makeStub()
|
||||
window.dispatchEvent(new Event('modrinth-cmp-ready'))
|
||||
|
||||
var uspStubFunction = function () {
|
||||
var arg = arguments
|
||||
|
||||
Reference in New Issue
Block a user