From 0a25916567041f66d0ed3796ccbe852d93030235 Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 17 Jul 2026 23:32:01 -0700 Subject: [PATCH 01/14] feat: implement custom popup --- apps/app-frontend/src/App.vue | 87 ++++++- apps/app-frontend/src/helpers/ads.js | 13 ++ .../app-frontend/src/locales/en-US/index.json | 15 ++ apps/app/build.rs | 4 + apps/app/src/api/ads-init.js | 213 +++++++++++++++++- apps/app/src/api/ads.rs | 135 ++++++++++- .../components/nav/PopupNotificationPanel.vue | 15 +- .../ui/src/providers/popup-notifications.ts | 2 + .../nav/PopupNotificationPanel.stories.ts | 35 +++ 9 files changed, 510 insertions(+), 9 deletions(-) diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 7910ca37a7..6bea7af0c6 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -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({ @@ -295,6 +304,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 +323,7 @@ onUnmounted(async () => { unsubscribeSidebarToggle() clearDelayedUpdatePopup() + await unlistenAdsConsent?.() await unlistenUpdateDownload?.() }) @@ -332,8 +348,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 rewards. Our partners may store unique identifiers 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', + autoCloseMs: null, + dismissible: false, + showOverModal: true, + 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, diff --git a/apps/app-frontend/src/helpers/ads.js b/apps/app-frontend/src/helpers/ads.js index 46f1500efa..ab6ae81344 100644 --- a/apps/app-frontend/src/helpers/ads.js +++ b/apps/app-frontend/src/helpers/ads.js @@ -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,18 @@ 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 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') } diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 7c481d1542..52be2a9475 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -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 rewards. Our partners may store unique identifiers 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." }, diff --git a/apps/app/build.rs b/apps/app/build.rs index da18e49769..2cd3c02fed 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -279,7 +279,11 @@ fn main() { "scroll_ads_window", "show_ads_window", "show_ads_consent_overlay", + "show_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", diff --git a/apps/app/src/api/ads-init.js b/apps/app/src/api/ads-init.js index f75fd9a1ce..ba53687064 100644 --- a/apps/app/src/api/ads-init.js +++ b/apps/app/src/api/ads-init.js @@ -27,6 +27,8 @@ window.open = (url, target, features) => { let modrinthAdsConsentOverlayShown = false let modrinthTcfListenerInstalled = false let modrinthTcfListenerAttempts = 0 +let modrinthAdsConsentActionRequestId = 0 +const modrinthAdsConsentActionResolvers = new Map() function installAdsConsentOverlayStyle() { if (document.getElementById('modrinth-ads-consent-overlay-style')) { @@ -35,10 +37,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 +71,213 @@ 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('[data-testid="accept-all"]') + 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( + '[data-testid="reject-all"], #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( + '[data-testid="manage-preferences"], .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') + } +} + +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.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) @@ -93,7 +302,7 @@ function handleTcfConsentEvent(tcData, success) { if (tcData.eventStatus === 'cmpuishown') { setAdsConsentOverlay(true) - } else if (tcData.eventStatus === 'useractioncomplete' || tcData.eventStatus === 'tcloaded') { + } else if (tcData.eventStatus === 'useractioncomplete') { setAdsConsentOverlay(false) } } diff --git a/apps/app/src/api/ads.rs b/apps/app/src/api/ads.rs index ba06283d0b..ef65ea8e24 100644 --- a/apps/app/src/api/ads.rs +++ b/apps/app/src/api/ads.rs @@ -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, @@ -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() -> TauriPlugin { 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() -> TauriPlugin { .map(|state| { state.shown && !state.modal_shown + && !state.consent_required && !state.consent_overlay_shown && !state.occluded }) @@ -358,7 +362,11 @@ pub fn init() -> TauriPlugin { hide_ads_window, show_ads_window, show_ads_consent_overlay, + show_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, @@ -677,12 +685,15 @@ pub async fn hide_ads_window( app: tauri::AppHandle, reset: Option, ) -> crate::api::Result<()> { + let reset = reset.unwrap_or(false); + if let Some(webview) = app.webviews().get("ads-window") { let state = app.state::>(); let mut state = state.write().await; - if reset.unwrap_or(false) { + if reset { state.shown = false; + state.consent_required = false; state.consent_overlay_shown = false; } else { state.modal_shown = true; @@ -706,6 +717,10 @@ pub async fn hide_ads_window( webview.hide().ok(); } + if reset { + app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok(); + } + Ok(()) } @@ -722,11 +737,45 @@ pub async fn show_ads_consent_overlay( 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( + app: tauri::AppHandle, +) -> crate::api::Result<()> { + if let Some(webview) = app.webviews().get("ads-window") { + let state = app.state::>(); + let mut state = state.write().await; + + if !state.shown || !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 +783,42 @@ pub async fn show_ads_consent_overlay( Ok(()) } +/// Restores the ad inventory bounds without resolving the pending consent request. +#[tauri::command] +pub async fn hide_ads_consent_preferences( + app: tauri::AppHandle, +) -> crate::api::Result<()> { + if let Some(webview) = app.webviews().get("ads-window") { + let state = app.state::>(); + 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( app: tauri::AppHandle, @@ -743,6 +828,7 @@ pub async fn hide_ads_consent_overlay( let state = app.state::>(); let mut state = state.write().await; + state.consent_required = false; state.consent_overlay_shown = false; if state.shown && !state.modal_shown { @@ -761,6 +847,47 @@ pub async fn hide_ads_consent_overlay( } } + app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok(); + + Ok(()) +} + +#[tauri::command] +pub async fn get_ads_consent_required( + app: tauri::AppHandle, +) -> crate::api::Result { + let state = app.state::>(); + let state = state.read().await; + + Ok(state.shown && state.consent_required) +} + +#[tauri::command] +pub async fn perform_ads_consent_action( + app: tauri::AppHandle, + 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::>(); + 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(()) } diff --git a/packages/ui/src/components/nav/PopupNotificationPanel.vue b/packages/ui/src/components/nav/PopupNotificationPanel.vue index cf35be5109..e40279feca 100644 --- a/packages/ui/src/components/nav/PopupNotificationPanel.vue +++ b/packages/ui/src/components/nav/PopupNotificationPanel.vue @@ -3,7 +3,7 @@ class="popup-notification-group" :class="{ 'has-sidebar': hasSidebar, - 'has-modal': hasModalActive, + 'has-modal': hasModalActive && !hasModalOverride, }" :style="notificationGroupStyle" > @@ -12,6 +12,7 @@ v-for="item in notifications" :key="item.id" class="popup-notification-wrapper" + :class="{ 'hidden-by-modal': hasModalActive && !item.showOverModal }" @mouseenter="stopTimer(item)" @mouseleave="setNotificationTimer(item)" > @@ -92,7 +93,12 @@ - + @@ -185,6 +191,7 @@ const notifications = computed(() => ) const { stackCount } = useModalStack() const hasModalActive = computed(() => stackCount.value > 0) +const hasModalOverride = computed(() => notifications.value.some((item) => item.showOverModal)) const notificationGroupStyle = computed(() => ({ zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200, })) @@ -326,6 +333,10 @@ withDefaults( width: 100%; } +.popup-notification-group .popup-notification-wrapper.hidden-by-modal { + display: none; +} + .popup-notifs-enter-active, .popup-notifs-leave-active, .popup-notifs-move { diff --git a/packages/ui/src/providers/popup-notifications.ts b/packages/ui/src/providers/popup-notifications.ts index 0251134af5..9efd46ae18 100644 --- a/packages/ui/src/providers/popup-notifications.ts +++ b/packages/ui/src/providers/popup-notifications.ts @@ -70,6 +70,8 @@ export interface PopupNotification { progressItems?: PopupNotificationProgressItem[] buttons?: PopupNotificationButton[] toast?: PopupNotificationToast + dismissible?: boolean + showOverModal?: boolean autoCloseMs?: number | null timer?: NodeJS.Timeout } diff --git a/packages/ui/src/stories/nav/PopupNotificationPanel.stories.ts b/packages/ui/src/stories/nav/PopupNotificationPanel.stories.ts index 81be795db6..bd1120082d 100644 --- a/packages/ui/src/stories/nav/PopupNotificationPanel.stories.ts +++ b/packages/ui/src/stories/nav/PopupNotificationPanel.stories.ts @@ -107,6 +107,37 @@ export const Default: StoryObj = { }) } + const showBlocking = () => { + popupManager.addPopupNotification({ + title: 'Your privacy and how ads support Modrinth', + text: 'Ads make Modrinth possible and fund creator rewards. Our partners may store unique identifiers to personalize ads and measure performance.', + type: 'info', + autoCloseMs: null, + dismissible: false, + showOverModal: true, + buttons: [ + { + label: 'Manage preferences', + action: () => console.log('Manage preferences clicked'), + color: 'standard', + keepOpen: true, + }, + { + label: 'Reject all', + action: () => console.log('Reject all clicked'), + color: 'brand', + keepOpen: true, + }, + { + label: 'Accept all', + action: () => console.log('Accept all clicked'), + color: 'brand', + keepOpen: true, + }, + ], + }) + } + const showWaitingProgress = () => { popupManager.addPopupNotification({ title: 'Installing modpack...', @@ -169,6 +200,7 @@ export const Default: StoryObj = { showInfo, showNoButtons, showPermanent, + showBlocking, showWaitingProgress, showDeterminateProgress, showGroupedDownloads, @@ -196,6 +228,9 @@ export const Default: StoryObj = { + + + From 43190ca1a284a686bb0750462eb4aeb56a30ce62 Mon Sep 17 00:00:00 2001 From: tdgao Date: Sat, 18 Jul 2026 12:34:00 -0700 Subject: [PATCH 02/14] feat: add privacy setting --- .../ui/settings/PrivacySettings.vue | 64 ++++++++++++++--- apps/app-frontend/src/helpers/ads.js | 4 ++ .../app-frontend/src/locales/en-US/index.json | 6 ++ apps/app/build.rs | 1 + apps/app/src/api/ads-init.js | 69 +++++++++++++++++++ apps/app/src/api/ads.rs | 31 +++++++++ 6 files changed, 165 insertions(+), 10 deletions(-) diff --git a/apps/app-frontend/src/components/ui/settings/PrivacySettings.vue b/apps/app-frontend/src/components/ui/settings/PrivacySettings.vue index 3482de94eb..89f3185b1e 100644 --- a/apps/app-frontend/src/components/ui/settings/PrivacySettings.vue +++ b/apps/app-frontend/src/components/ui/settings/PrivacySettings.vue @@ -1,12 +1,47 @@