mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +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:
@@ -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>
|
||||
Reference in New Issue
Block a user