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:
Truman Gao
2026-07-20 20:31:10 +00:00
committed by GitHub
parent 932858b5f5
commit 8e44f20eec
20 changed files with 1142 additions and 54 deletions
+5
View File
@@ -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",
+282 -2
View File
@@ -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
View File
@@ -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(())