Compare commits

...
20 changed files with 1142 additions and 54 deletions
+114 -21
View File
@@ -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">
+17
View File
@@ -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"
},
+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(())
+2
View File
@@ -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>
+1
View File
@@ -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),
},
+3
View File
@@ -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
@@ -16,26 +16,32 @@
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
<div
class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl border border-solid border-surface-5"
:class="item.containerClass"
>
<div
class="w-2"
:class="{
'bg-red': item.type === 'error',
'bg-orange': item.type === 'warning',
'bg-green': item.type === 'success',
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
'bg-blue': !item.type || item.type === 'info',
'bg-transparent': item.type === 'neutral',
}"
></div>
<div
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
>
<div
v-if="!item.noIcon"
class="flex items-center"
:class="{
'text-red': item.type === 'error',
'text-orange': item.type === 'warning',
'text-green': item.type === 'success',
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
'text-blue': !item.type || item.type === 'info',
'text-contrast': item.type === 'neutral',
}"
>
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
@@ -43,12 +49,17 @@
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
<InfoIcon v-else class="h-6 w-6" />
</div>
<div class="m-0 text-wrap font-bold text-contrast">{{ item.title }}</div>
<div
class="m-0 text-wrap font-bold text-contrast"
:class="{ 'col-span-2': item.noIcon }"
>
{{ item.title }}
</div>
<div class="flex items-center gap-1">
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
x{{ item.count }}
</div>
<ButtonStyled circular size="small">
<ButtonStyled v-if="item.copyable !== false" circular size="small">
<button
v-tooltip="
item.supportData ? 'Copy error details for support' : 'Copy to clipboard'
@@ -59,13 +70,13 @@
<CopyIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled circular size="small">
<ButtonStyled v-if="item.dismissible !== false" circular size="small">
<button v-tooltip="`Dismiss`" @click="dismissNotification(index)">
<XIcon />
</button>
</ButtonStyled>
</div>
<div></div>
<div v-if="item.type !== 'neutral'"></div>
<div class="col-span-2 text-sm text-primary">{{ item.text }}</div>
<template v-if="item.errorCode">
<div></div>
@@ -73,6 +84,20 @@
{{ item.errorCode }}
</div>
</template>
<template v-if="item.buttons?.length">
<div class="col-span-2 flex flex-wrap gap-1.5 pt-1">
<ButtonStyled
v-for="(button, buttonIndex) in item.buttons"
:key="buttonIndex"
:color="button.color"
>
<button class="!shadow-none" @click="handleButtonClick(item, button)">
<component :is="button.icon" v-if="button.icon" />
{{ button.label }}
</button>
</ButtonStyled>
</div>
</template>
</div>
</div>
</div>
@@ -94,7 +119,11 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useModalStack } from '#ui/composables/modal-stack.ts'
import { injectNotificationManager, type WebNotification } from '../../providers'
import {
injectNotificationManager,
type WebNotification,
type WebNotificationButton,
} from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
const notificationManager = injectNotificationManager()
@@ -108,6 +137,13 @@ const stopTimer = (n: WebNotification) => notificationManager.stopNotificationTi
const setNotificationTimer = (n: WebNotification) => notificationManager.setNotificationTimer(n)
const dismissNotification = (n: number) => notificationManager.removeNotificationByIndex(n)
async function handleButtonClick(item: WebNotification, button: WebNotificationButton) {
await button.action()
if (!button.keepOpen) {
notificationManager.removeNotification(item.id)
}
}
function createNotifText(notif: WebNotification): string {
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
}
@@ -170,7 +206,7 @@ withDefaults(
position: fixed;
bottom: 1.5rem;
z-index: 200;
width: 450px;
width: 460px;
transition: bottom 0.25s ease-in-out;
&.location-right {
@@ -70,6 +70,7 @@
/>
<template v-else>
<div
v-if="!item.hideIcon"
class="flex items-center"
:class="{
'text-red': item.type === 'error',
@@ -92,8 +93,8 @@
</div>
</template>
</div>
<ButtonStyled size="small" type="transparent" circular>
<button @click="dismiss(item.id)">
<ButtonStyled v-if="item.dismissible !== false" type="transparent" circular>
<button class="-m-1.5" @click="dismiss(item.id)">
<XIcon />
</button>
</ButtonStyled>
@@ -144,7 +145,7 @@
:key="idx"
:color="btn.color || (idx === 0 ? 'brand' : undefined)"
>
<button @click="handleButtonClick(item.id, btn)">
<button class="!shadow-none" @click="handleButtonClick(item.id, btn)">
<component :is="btn.icon" v-if="btn.icon" />
{{ btn.label }}
</button>
@@ -295,9 +296,9 @@ withDefaults(
top: calc(var(--top-bar-height, 3rem) + 1.5rem);
right: 1.5rem;
z-index: 200;
width: min(420px, calc(100vw - 1.5rem));
min-width: min(420px, calc(100vw - 1.5rem));
max-width: min(420px, calc(100vw - 1.5rem));
width: min(440px, calc(100vw - 1.5rem));
min-width: min(440px, calc(100vw - 1.5rem));
max-width: min(440px, calc(100vw - 1.5rem));
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -6,6 +6,7 @@ export interface PageContext {
// pages may render sidebar content in #sidebar-teleport-target instead of in the main layout when true
hierarchicalSidebarAvailable: Ref<boolean>
showAds: Ref<boolean>
adConsentAvailable: Ref<boolean>
floatingActionBarOffsets?: {
left: Ref<string> | ComputedRef<string>
right: Ref<string> | ComputedRef<string>
@@ -64,12 +64,14 @@ export interface PopupNotification {
bodyProps?: Record<string, unknown>
text?: string
iconUrl?: string | null
hideIcon?: boolean
type?: 'error' | 'warning' | 'success' | 'info' | 'download'
progress?: number
waiting?: boolean
progressItems?: PopupNotificationProgressItem[]
buttons?: PopupNotificationButton[]
toast?: PopupNotificationToast
dismissible?: boolean
autoCloseMs?: number | null
timer?: NodeJS.Timeout
}
+16 -1
View File
@@ -1,15 +1,30 @@
import type { Component } from 'vue'
import { createContext } from '.'
export interface WebNotificationButton {
label: string
action: () => void | Promise<void>
icon?: Component
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard'
keepOpen?: boolean
}
export interface WebNotification {
id: string | number
title?: string
text?: string
type?: 'error' | 'warning' | 'success' | 'info'
type?: 'error' | 'warning' | 'success' | 'info' | 'neutral'
errorCode?: string
count?: number
autoCloseMs?: number | null // null means do not dismiss automatically
timer?: NodeJS.Timeout
supportData?: Record<string, unknown>
containerClass?: string
noIcon?: boolean
buttons?: WebNotificationButton[]
dismissible?: boolean
copyable?: boolean
}
export type NotificationPanelLocation = 'left' | 'right'
@@ -50,11 +50,39 @@ export const Default: StoryObj = {
})
}
const showActions = () => {
notificationManager.addNotification({
title: 'Your privacy and how ads support Modrinth',
text: 'Choose how our advertising partners may use your data.',
type: 'neutral',
autoCloseMs: null,
dismissible: false,
copyable: false,
noIcon: true,
buttons: [
{
label: 'Manage preferences',
action: () => console.log('Manage preferences clicked'),
},
{
label: 'Reject all',
action: () => console.log('Reject all clicked'),
color: 'brand',
},
{
label: 'Accept all',
action: () => console.log('Accept all clicked'),
color: 'brand',
},
],
})
}
const clearAll = () => {
notificationManager.clearAllNotifications()
}
return { showSuccess, showError, showWarning, showInfo, clearAll }
return { showSuccess, showError, showWarning, showInfo, showActions, clearAll }
},
template: /* html */ `
<div>
@@ -71,6 +99,9 @@ export const Default: StoryObj = {
<ButtonStyled color="blue">
<button @click="showInfo">Info</button>
</ButtonStyled>
<ButtonStyled>
<button @click="showActions">Actions</button>
</ButtonStyled>
<ButtonStyled>
<button @click="clearAll">Clear All</button>
</ButtonStyled>
@@ -107,6 +107,36 @@ 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,
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 +199,7 @@ export const Default: StoryObj = {
showInfo,
showNoButtons,
showPermanent,
showBlocking,
showWaitingProgress,
showDeterminateProgress,
showGroupedDownloads,
@@ -196,6 +227,9 @@ export const Default: StoryObj = {
<ButtonStyled>
<button @click="showPermanent">Permanent</button>
</ButtonStyled>
<ButtonStyled>
<button @click="showBlocking">Blocking</button>
</ButtonStyled>
<ButtonStyled>
<button @click="showWaitingProgress">Waiting Progress</button>
</ButtonStyled>