From a289d04a6984edea3f440cca0fabd3e7faa73532 Mon Sep 17 00:00:00 2001 From: Lawrence Hook Date: Sun, 19 Apr 2026 20:21:08 -0400 Subject: [PATCH] version 4.3.79 --- src/background/events.js | 11 +-- src/chrome_manifest.json | 3 +- src/content-script/main.js | 47 ++++++++----- src/firefox_manifest.json | 3 +- src/options/body.css | 121 ++++++++++++++++++++++--------- src/options/header.css | 17 +++++ src/options/main.html | 11 ++- src/options/main.js | 133 +++++++++++++++++++++++------------ src/options/settings-menu.js | 115 ++++++++++++++++++++---------- src/shared/config.js | 10 +++ src/shared/license.js | 6 ++ src/shared/main.js | 44 ++++++++++++ 12 files changed, 378 insertions(+), 143 deletions(-) diff --git a/src/background/events.js b/src/background/events.js index 544a646..4d9cded 100644 --- a/src/background/events.js +++ b/src/background/events.js @@ -8,11 +8,14 @@ if (typeof browser === 'undefined') { const uninstallUrl = "http://lawrencehook.com/rys/👋"; browser.runtime.setUninstallURL(uninstallUrl); -browser.runtime.onInstalled.addListener(object => { +browser.runtime.onInstalled.addListener(async object => { const url = "http://lawrencehook.com/rys/welcome"; - if (object.reason === browser.runtime.OnInstalledReason.INSTALL) { - browser.tabs.create({ url }, tab => {}); - } + if (object.reason !== browser.runtime.OnInstalledReason.INSTALL) return; + // management.getSelf() returns a Promise in both Chrome MV3 and Firefox + // (Firefox ignores callbacks), so use await for cross-browser consistency. + const info = await browser.management.getSelf(); + if (info && info.installType === 'development') return; // skip on unpacked/dev loads + browser.tabs.create({ url }); }); // Change the browserAction icon if the extension is disabled diff --git a/src/chrome_manifest.json b/src/chrome_manifest.json index cf08fda..da6cf3a 100644 --- a/src/chrome_manifest.json +++ b/src/chrome_manifest.json @@ -3,7 +3,7 @@ "description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.", "homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions", "manifest_version": 3, - "version": "4.3.78", + "version": "4.3.79", "icons": { "16": "/images/16.png", @@ -20,6 +20,7 @@ { "js": [ "/shared/main.js", + "/shared/config.js", "/shared/https.js", "/shared/utils.js", "/shared/banners.js", diff --git a/src/content-script/main.js b/src/content-script/main.js index be4518e..2dce7a3 100644 --- a/src/content-script/main.js +++ b/src/content-script/main.js @@ -53,11 +53,6 @@ let lastRedirect; const redirectInterval = 1_000; // 1 second -// Get list of premium feature IDs -const PREMIUM_FEATURE_IDS = SECTIONS.flatMap(section => - section.options.filter(opt => opt.premium).map(opt => opt.id) -); - // Decode license token to check premium status (no signature verification - we trust our server) function decodeLicenseToken(token) { if (!token) return null; @@ -77,21 +72,35 @@ function isPremiumFromToken(token) { return decoded.premium === true && decoded.exp > now; } +function getTier(licenseToken, sessionToken) { + if (isPremiumFromToken(licenseToken)) return TIER.PREMIUM; + if (sessionToken) return TIER.FREE_SIGNED_IN; + return TIER.FREE; +} + // Respond to changes in settings function logStorageChange(changes, area) { if (area !== 'local') return; - // Update premium status in cache if license token changed - if ('license_token' in changes) { - cache['_isPremium'] = isPremiumFromToken(changes['license_token'].newValue); + if ('license_token' in changes) cache['_licenseToken'] = changes['license_token'].newValue; + if ('session_token' in changes) cache['_sessionToken'] = changes['session_token'].newValue; + if ('license_token' in changes || 'session_token' in changes) { + cache['_tier'] = getTier(cache['_licenseToken'], cache['_sessionToken']); } Object.entries(changes).forEach(([id, { oldValue, newValue }]) => { if (oldValue === newValue) return; - // Enforce premium features are false for non-premium users - if (PREMIUM_FEATURE_IDS.includes(id) && cache['_isPremium'] !== true) { - newValue = false; + if (PREMIUM_FEATURE_ID_SET.has(id)) { + const tier = cache['_tier']; + if (tier === TIER.FREE) { + newValue = false; + } else if (tier === TIER.FREE_SIGNED_IN && newValue === true) { + const alreadyOn = cache[id] === true; + if (!alreadyOn && countActivePremium(cache) >= PREMIUM_CONFIG.FREE_PREMIUM_SLOTS) { + newValue = false; + } + } } HTML.setAttribute(id, newValue); @@ -113,13 +122,15 @@ browser.storage.local.get(settings => { browser.storage.local.set(revealUpdates); } - // Enforce premium features are false for non-premium users - const isPremium = isPremiumFromToken(settings['license_token']); - cache['_isPremium'] = isPremium; - if (!isPremium) { - PREMIUM_FEATURE_IDS.forEach(id => { - settings[id] = false; - }); + const tier = getTier(settings['license_token'], settings['session_token']); + cache['_tier'] = tier; + cache['_licenseToken'] = settings['license_token']; + cache['_sessionToken'] = settings['session_token']; + if (tier === TIER.FREE) { + clearAllPremium(settings); + } else if (tier === TIER.FREE_SIGNED_IN) { + const writeBack = enforceSlotBudget(settings, PREMIUM_CONFIG.FREE_PREMIUM_SLOTS); + if (Object.keys(writeBack).length) browser.storage.local.set(writeBack); } Object.entries({ ...DEFAULT_SETTINGS, ...settings}).forEach(([ id, value ]) => { diff --git a/src/firefox_manifest.json b/src/firefox_manifest.json index 712c317..6c444d8 100644 --- a/src/firefox_manifest.json +++ b/src/firefox_manifest.json @@ -3,7 +3,7 @@ "description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.", "homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions", "manifest_version": 2, - "version": "4.3.78", + "version": "4.3.79", "icons": { "16": "/images/16.png", @@ -23,6 +23,7 @@ { "js": [ "/shared/main.js", + "/shared/config.js", "/shared/https.js", "/shared/utils.js", "/shared/banners.js", diff --git a/src/options/body.css b/src/options/body.css index 4e92c10..5a12214 100644 --- a/src/options/body.css +++ b/src/options/body.css @@ -318,7 +318,21 @@ html[foo=bar] { } #password_container_background button { - color: var(--body-color); + padding: 8px 16px; + font-size: 0.85rem; + font-weight: 500; + border: none; + border-radius: var(--border-radius); + cursor: pointer; + background-color: var(--blue-color); + color: white; +} +#password_container_background button:hover { + opacity: 0.9; +} +#password_container_background button:disabled { + opacity: 0.6; + cursor: not-allowed; } #enable_password_container, #enable_password_container * { @@ -381,10 +395,6 @@ html[foo=bar] { justify-self: start; } -#password-button button { - color: var(--body-color); -} - #disable_password_container a:first-child { font-weight: bold; } @@ -592,6 +602,23 @@ html[dark_mode='true'] #active_sidebar .active_count { gap: 1px; } +#current_selection_indicator { + position: sticky; + top: 0; + align-self: flex-end; + margin-bottom: -28px; + font-size: 0.8rem; + color: var(--label-color); + opacity: 0.75; + padding: 4px 10px; + background-color: var(--body-background-color); + border-left: 1px solid var(--box-shadow-color); + border-bottom: 1px solid var(--box-shadow-color); + border-bottom-left-radius: 6px; + pointer-events: none; + z-index: 5; +} + /************************************* * SIGN-IN / ACCOUNT / UPGRADE MODALS @@ -926,6 +953,7 @@ html[dark_mode='true'] #active_sidebar .active_count { /* Upgrade Modal */ #upgrade_container { width: auto; + max-width: min(90vw, 380px); padding: 24px; gap: 18px; align-items: flex-start; @@ -940,8 +968,29 @@ html[dark_mode='true'] #active_sidebar .active_count { } #premium_required_description { - font-size: 0.85rem; margin: 0; + text-align: center; +} + +#upgrade_slot_limit_note { + color: var(--body-color); + text-align: center; + padding: 0 8px; +} + +.premium-note-main, +.premium-note-sub { + display: block; + line-height: 1.4; +} +.premium-note-main { + font-size: 0.9rem; + opacity: 0.85; +} +.premium-note-sub { + font-size: 0.8rem; + opacity: 0.55; + margin-top: 4px; } #upgrade_plans { @@ -1026,53 +1075,55 @@ html[dark_mode='true'] .upgrade-plan[selected] { align-self: flex-end; } -/* Premium lock icon for options */ -html:not([is_premium='true']) .option[data-premium='true'] { - opacity: 0.25; - position: relative; +/* Premium options: dim slightly for non-premium users, full opacity for premium */ +html:not([tier='premium']) .option[data-premium='true'] { + opacity: 0.65; +} +html[tier='premium'] .option[data-premium='true'], +#active_section .option[data-premium='true'] { + opacity: 1; } -html:not([is_premium='true']) .option[data-premium='true']::after { +/* Blue dot at the right edge of premium options (only shown for non-premium users). */ +.option[data-premium='true'] { + position: relative; +} +html:not([tier='premium']) .option[data-premium='true']::after { content: ''; position: absolute; right: 10px; top: 50%; transform: translateY(-50%); - width: 16px; - height: 16px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z'/%3E%3C/svg%3E"); - background-size: contain; - background-repeat: no-repeat; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--blue-color, #4a6cf7); + opacity: 0.85; } -/* Premium options unlocked */ -html[is_premium='true'] .option[data-premium='true'] { - opacity: 1; -} - -/* Premium lock icon for settings menu items */ -html:not([is_premium='true']) #settings-menu div[data-premium='true'], -html:not([is_premium='true']) #power-options div[data-premium='true'] { - opacity: 0.25; +/* Premium marker for settings-menu / power-options items (same dot as main list) */ +html:not([tier='premium']) #settings-menu div[data-premium='true'], +html:not([tier='premium']) #power-options div[data-premium='true'] { + opacity: 0.65; position: relative; } -html:not([is_premium='true']) #settings-menu div[data-premium='true']::after, -html:not([is_premium='true']) #power-options div[data-premium='true']::after { +html:not([tier='premium']) #settings-menu div[data-premium='true']::after, +html:not([tier='premium']) #power-options div[data-premium='true']::after { content: ''; position: absolute; - right: 8px; + right: 10px; top: 50%; transform: translateY(-50%); - width: 14px; - height: 14px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z'/%3E%3C/svg%3E"); - background-size: contain; - background-repeat: no-repeat; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--blue-color, #4a6cf7); + opacity: 0.85; } -html[is_premium='true'] #settings-menu div[data-premium='true'], -html[is_premium='true'] #power-options div[data-premium='true'] { +html[tier='premium'] #settings-menu div[data-premium='true'], +html[tier='premium'] #power-options div[data-premium='true'] { opacity: 1; } diff --git a/src/options/header.css b/src/options/header.css index 2b5a10f..c9362d3 100644 --- a/src/options/header.css +++ b/src/options/header.css @@ -54,6 +54,23 @@ html[dark_mode='false'] #header-light { display: none } letter-spacing: 0.02em; } +#header-slot-indicator { + display: flex; + align-items: center; + gap: 4px; +} +#header-slot-indicator .slot-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background-color: var(--label-color); + opacity: 0.25; +} +#header-slot-indicator .slot-dot[filled] { + background-color: var(--blue-color, #4a6cf7); + opacity: 0.85; +} + #search_bar { margin-left: auto; margin-right: auto; diff --git a/src/options/main.html b/src/options/main.html index 40ecdf4..8f4cba9 100644 --- a/src/options/main.html +++ b/src/options/main.html @@ -190,7 +190,10 @@ Premium -

Premium required. Sign in to upgrade.

+

+ Sign in to enable any 2 premium features. + Upgrade to unlock them all. +

@@ -208,6 +211,10 @@
Upgrade +
Monthly
@@ -292,6 +299,8 @@
+ +
Button is disabled because the schedule is active.
diff --git a/src/options/main.js b/src/options/main.js index 901fd9d..9504795 100644 --- a/src/options/main.js +++ b/src/options/main.js @@ -24,14 +24,13 @@ document.addEventListener("DOMContentLoaded", () => { const settings = { ...DEFAULT_SETTINGS, ...localSettings }; - // Enforce premium features are disabled for non-premium users - const isPremium = License.isPremiumSync(localSettings['license_token']); - if (!isPremium) { - SECTIONS.forEach(section => { - section.options.forEach(opt => { - if (opt.premium) settings[opt.id] = false; - }); - }); + const tier = License.getTierSync(localSettings['license_token'], localSettings['session_token']); + HTML.setAttribute('tier', tier); + if (tier === TIER.FREE) { + clearAllPremium(settings); + } else if (tier === TIER.FREE_SIGNED_IN) { + const writeBack = enforceSlotBudget(settings, PREMIUM_CONFIG.FREE_PREMIUM_SLOTS); + if (Object.keys(writeBack).length) browser.storage.local.set(writeBack); } const headerSettings = Object.entries(OTHER_SETTINGS).reduce((acc, [id, value]) => { @@ -88,28 +87,13 @@ function refreshActiveSection() { optionNode.classList.remove('removed'); optionNode.removeAttribute('id'); optionNode.setAttribute('name', name); + if (premium) optionNode.setAttribute('data-premium', 'true'); optionNode.querySelector('.option_label').innerText = name; const svg = optionNode.querySelector('svg'); svg.toggleAttribute('active', true); optionNode.addEventListener('click', async e => { - if (premium && HTML.getAttribute('is_premium') !== 'true') { - const signedIn = await Auth.isSignedIn(); - if (!signedIn) { - if (typeof openPremiumRequiredModal === 'function') { - openPremiumRequiredModal(); - } else { - displayStatus('Premium feature - sign in to upgrade'); - } - } else if (typeof openUpgradeModal === 'function') { - openUpgradeModal(); - } else { - displayStatus('Premium feature - upgrade to access'); - } - return; - } - updateSetting(id, false, { manual: true }); // Sync the original toggle const originalSvg = document.querySelector(`div#${id} svg`); @@ -144,6 +128,10 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) { SIDEBAR.innerHTML = ''; let allTags = []; + const selectionIndicator = document.createElement('div'); + selectionIndicator.id = 'current_selection_indicator'; + OPTIONS_LIST.append(selectionIndicator); + // Create the Active Options section (hidden until sidebar selected) const activeSection = TEMPLATE_SECTION.cloneNode(true); activeSection.id = 'active_section'; @@ -191,24 +179,16 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) { svg.toggleAttribute('active', value); optionNode.addEventListener('click', async e => { - // Check if premium-locked - if (premium && HTML.getAttribute('is_premium') !== 'true') { - // Check if signed in first - const signedIn = await Auth.isSignedIn(); - if (!signedIn) { - // Not signed in - show premium required modal - if (typeof openPremiumRequiredModal === 'function') { - openPremiumRequiredModal(); - } else { - displayStatus('Premium feature - sign in to upgrade'); - } - } else if (typeof openUpgradeModal === 'function') { - // Signed in but not premium - open upgrade modal - openUpgradeModal(); - } else { - displayStatus('Premium feature - upgrade to access'); + const tier = HTML.getAttribute('tier'); + if (premium && tier !== TIER.PREMIUM) { + const togglingOn = cache[id] !== true; + const hasSlot = tier === TIER.FREE_SIGNED_IN && + countActivePremium(cache) < PREMIUM_CONFIG.FREE_PREMIUM_SLOTS; + if (togglingOn && !hasSlot) { + if (tier === TIER.FREE_SIGNED_IN) openUpgradeModal({ reason: 'slot_limit' }); + else openPremiumRequiredModal(); + return; } - return; } const value = svg.toggleAttribute('active'); @@ -229,10 +209,16 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) { OPTIONS_LIST.append(sectionNode); }); - // Add "All" and "Active" sidebar items at the top + // Add "Free", "All", and "Active" sidebar items at the top const sidebarTopRow = document.createElement('div'); sidebarTopRow.id = 'sidebar_top_row'; + const freeSidebar = document.createElement('div'); + freeSidebar.id = 'free_sidebar'; + freeSidebar.className = 'sidebar_section'; + freeSidebar.innerText = 'free'; + freeSidebar.addEventListener('click', freeSidebarListener); + const allSidebar = document.createElement('div'); allSidebar.id = 'all_sidebar'; allSidebar.className = 'sidebar_section'; @@ -245,7 +231,7 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) { activeSidebar.innerHTML = 'active'; activeSidebar.addEventListener('click', activeSidebarListener); - sidebarTopRow.append(allSidebar, activeSidebar); + sidebarTopRow.append(freeSidebar, allSidebar, activeSidebar); SIDEBAR.append(sidebarTopRow); // Add sections to the sidebar @@ -276,8 +262,11 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) { }); } - // Pre-select sidebar option based on current window. Default to 'Basic' - if (resultsPageRegex.test(currentUrl)) { + // Pre-select sidebar. Non-premium users default to "free"; others use URL-based section. + const tier = License.getTierSync(SETTING_VALUES['license_token'], SETTING_VALUES['session_token']); + if (tier !== TIER.PREMIUM) { + qs('#free_sidebar').click(); + } else if (resultsPageRegex.test(currentUrl)) { qs('.sidebar_section[tag="Search"]').click(); } else if (videoPageRegex.test(currentUrl)) { qs('.sidebar_section[tag="Video Player"]').click(); @@ -291,6 +280,8 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) { qs('.sidebar_section[tag="Basic"]').click(); } + updateSlotIndicator(); + const searchBar = document.getElementById('search_bar'); searchBar.addEventListener('input', onSearchInput); const searchInput = searchBar.querySelector('input'); @@ -388,6 +379,18 @@ function updateTimeInfo() { function updateSetting(id, value, { write=true, manual=false }={}) { + // Clamp premium writes that would exceed the free-tier slot budget, so chained + // effects behave the same as direct toggles. + if (PREMIUM_FEATURE_ID_SET.has(id) && value === true) { + const tier = HTML.getAttribute('tier'); + if (tier === TIER.FREE) { + value = false; + } else if (tier === TIER.FREE_SIGNED_IN && cache[id] !== true && + countActivePremium(cache) >= PREMIUM_CONFIG.FREE_PREMIUM_SLOTS) { + value = false; + } + } + if (manual) recordEvent('Setting changed', { id, value }); HTML.setAttribute(id, value); @@ -414,6 +417,8 @@ function updateSetting(id, value, { write=true, manual=false }={}) { } refreshActiveSection(); + + if (PREMIUM_FEATURE_ID_SET.has(id)) updateSlotIndicator(); } @@ -426,6 +431,7 @@ function onSearchInput(e) { // Deselect "all" during active search const allSidebar = document.getElementById('all_sidebar'); if (allSidebar) allSidebar.removeAttribute('selected'); + setSelectionLabel(`"${value}"`); const sections = qsa('.section_container:not(#template_section):not(#active_section)'); const searchTerms = value.toLowerCase().split(' '); @@ -457,6 +463,12 @@ function onSearchInput(e) { } +function setSelectionLabel(text) { + const el = document.getElementById('current_selection_indicator'); + if (el) el.textContent = text; +} + + function showAll() { const sidebarSections = qsa('.sidebar_section'); const sections = qsa('.section_container:not(#template_section):not(#active_section)'); @@ -470,6 +482,35 @@ function showAll() { section.querySelectorAll('div.option').forEach(opt => opt.classList.remove('removed')); }); if (activeSection) activeSection.classList.add('removed'); + setSelectionLabel('all'); +} + + +function freeSidebarListener(e) { + const target = e.currentTarget; + qsa('.sidebar_section').forEach(s => { + if (s !== target) s.removeAttribute('selected'); + }); + target.setAttribute('selected', ''); + + const activeSection = document.getElementById('active_section'); + if (activeSection) activeSection.classList.add('removed'); + + const sections = qsa('.section_container:not(#template_section):not(#active_section)'); + sections.forEach(section => { + section.classList.remove('removed'); + let hasFreeOption = false; + section.querySelectorAll('div.option').forEach(opt => { + if (opt.getAttribute('data-premium') === 'true') { + opt.classList.add('removed'); + } else { + opt.classList.remove('removed'); + hasFreeOption = true; + } + }); + if (!hasFreeOption) section.classList.add('removed'); + }); + setSelectionLabel('free'); } @@ -484,6 +525,7 @@ function activeSidebarListener(e) { }); qsa('.section_container:not(#template_section):not(#active_section)').forEach(s => s.classList.add('removed')); refreshActiveSection(); + setSelectionLabel('active'); } else { showAll(); } @@ -526,6 +568,7 @@ function sidebarSectionListener(e) { section.classList.add('removed'); } }); + setSelectionLabel(tag); } diff --git a/src/options/settings-menu.js b/src/options/settings-menu.js index 27d40a4..5acd3e9 100644 --- a/src/options/settings-menu.js +++ b/src/options/settings-menu.js @@ -82,18 +82,12 @@ function openScheduleModal() { updateDays(scheduleDays); }); } -SCHEDULING_OPTION.addEventListener('click', async e => { - if (HTML.getAttribute('is_premium') !== 'true') { - await handlePremiumFeatureClick(); - return; - } +SCHEDULING_OPTION.addEventListener('click', e => { + if (!canUsePremiumFeature('schedule')) return; openScheduleModal(); }); -OPEN_SCHEDULE_OPTION.addEventListener('click', async e => { - if (HTML.getAttribute('is_premium') !== 'true') { - await handlePremiumFeatureClick(); - return; - } +OPEN_SCHEDULE_OPTION.addEventListener('click', e => { + if (!canUsePremiumFeature('schedule')) return; openScheduleModal(); }); RESUME_SCHEDULE_OPTION.addEventListener('click', e => { @@ -192,11 +186,8 @@ function openPasswordModal() { DISABLE_PASSWORD_CONTAINER.toggleAttribute('hidden', !password); }); } -PASSWORD_OPTION.addEventListener('click', async e => { - if (HTML.getAttribute('is_premium') !== 'true') { - await handlePremiumFeatureClick(); - return; - } +PASSWORD_OPTION.addEventListener('click', e => { + if (!canUsePremiumFeature('password')) return; openPasswordModal(); }); @@ -396,6 +387,28 @@ const ACCOUNT_OPTION = qs('#settings-account'); const DONATE_LINK = qs('#settings-donate'); const DONATE_URL = 'https://www.paypal.com/donate/?cmd=_donations&business=FF9K9YD6K6SWG&Z3JncnB0='; const HEADER_PREMIUM_BADGE = qs('#header-premium-badge'); +const HEADER_SLOT_INDICATOR = qs('#header-slot-indicator'); +if (HEADER_SLOT_INDICATOR) { + for (let i = 0; i < PREMIUM_CONFIG.FREE_PREMIUM_SLOTS; i++) { + const dot = document.createElement('span'); + dot.className = 'slot-dot'; + HEADER_SLOT_INDICATOR.appendChild(dot); + } +} + +function updateSlotIndicator() { + if (!HEADER_SLOT_INDICATOR) return; + if (HTML.getAttribute('tier') !== TIER.FREE_SIGNED_IN) { + HEADER_SLOT_INDICATOR.setAttribute('hidden', ''); + return; + } + const used = countActivePremium(cache); + HEADER_SLOT_INDICATOR.querySelectorAll('.slot-dot').forEach((dot, i) => { + dot.toggleAttribute('filled', i < used); + }); + HEADER_SLOT_INDICATOR.title = `${used}/${PREMIUM_CONFIG.FREE_PREMIUM_SLOTS} free premium features used`; + HEADER_SLOT_INDICATOR.removeAttribute('hidden'); +} // Sign-in modal elements const signinModalContainer = qs('#signin_container_background'); @@ -475,10 +488,15 @@ async function initAccountState() { const signedIn = await Auth.isSignedIn(); if (signedIn) { ACCOUNT_OPTION.textContent = 'Account'; + // Provisionally treat as free_signed_in until license check returns + HTML.setAttribute('tier', TIER.FREE_SIGNED_IN); + updateSlotIndicator(); // Check license in background refreshLicense(true); } else { ACCOUNT_OPTION.textContent = 'Sign In'; + HTML.setAttribute('tier', TIER.FREE); + updateSlotIndicator(); // Auto-open sign-in modal if ?signin=1 param is present const params = new URLSearchParams(window.location.search); if (params.get('signin') === '1') { @@ -488,27 +506,45 @@ async function initAccountState() { } initAccountState(); +// Turn off every premium feature (including menu-level ones like schedule/password). +// Used when dropping to the `free` tier via sign-out or license revocation. +function disableAllPremiumFeatures() { + PREMIUM_FEATURE_IDS.forEach(id => updateSetting(id, false)); +} + +// When transitioning into `free_signed_in` (e.g. subscription lapsed while the +// options page is open), disable any premium features beyond FREE_PREMIUM_SLOTS +// so current state matches the new tier. Reuses the shared enforceSlotBudget +// helper and routes writes through updateSetting so the full UI stays in sync. +function pruneToSlotBudget() { + const overBudget = enforceSlotBudget(cache, PREMIUM_CONFIG.FREE_PREMIUM_SLOTS); + Object.keys(overBudget).forEach(id => updateSetting(id, false)); +} + function updatePremiumUI(licenseData) { if (licenseData && licenseData.signedOut) { ACCOUNT_OPTION.textContent = 'Sign In'; - HTML.setAttribute('is_premium', 'false'); + HTML.setAttribute('tier', TIER.FREE); + disableAllPremiumFeatures(); if (DONATE_LINK) { DONATE_LINK.hidden = false; DONATE_LINK.textContent = 'Donate'; DONATE_LINK.setAttribute('href', DONATE_URL); } if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', ''); + updateSlotIndicator(); return; } if (licenseData && licenseData.isPremium) { - HTML.setAttribute('is_premium', 'true'); + HTML.setAttribute('tier', TIER.PREMIUM); if (DONATE_LINK) { DONATE_LINK.hidden = true; } if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.removeAttribute('hidden'); } else { - HTML.setAttribute('is_premium', 'false'); + HTML.setAttribute('tier', TIER.FREE_SIGNED_IN); + pruneToSlotBudget(); if (DONATE_LINK) { DONATE_LINK.hidden = false; DONATE_LINK.textContent = 'Donate'; @@ -516,6 +552,7 @@ function updatePremiumUI(licenseData) { } if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', ''); } + updateSlotIndicator(); } // Account option click handler @@ -674,13 +711,9 @@ accountModalContainer.addEventListener('click', e => { accountSignoutButton.addEventListener('click', async () => { await Auth.signOut(); ACCOUNT_OPTION.textContent = 'Sign In'; - HTML.setAttribute('is_premium', 'false'); - // Disable all premium options - SECTIONS.forEach(section => { - section.options.forEach(option => { - if (option.premium) updateSetting(option.id, false); - }); - }); + HTML.setAttribute('tier', TIER.FREE); + updateSlotIndicator(); + disableAllPremiumFeatures(); if (DONATE_LINK) { DONATE_LINK.textContent = 'Donate'; DONATE_LINK.setAttribute('href', DONATE_URL); @@ -713,24 +746,30 @@ accountBillingButton.addEventListener('click', async () => { }); // Upgrade modal -function openUpgradeModal() { - recordEvent('Upgrade Modal Opened'); +function openUpgradeModal(options = {}) { + const { reason } = options; + recordEvent('Upgrade Modal Opened', { reason: reason || 'default' }); + const slotNote = document.getElementById('upgrade_slot_limit_note'); + if (slotNote) { + if (reason === 'slot_limit') slotNote.removeAttribute('hidden'); + else slotNote.setAttribute('hidden', ''); + } upgradeModalContainer.removeAttribute('hidden'); selectPlan('monthly'); } -// Premium feature click handler - checks sign-in first -async function handlePremiumFeatureClick() { - const signedIn = await Auth.isSignedIn(); - if (!signedIn) { - // Not signed in - show premium required modal - recordEvent('Premium Feature Click', { signedIn: false }); - openPremiumRequiredModal(); - return; +// Gate a menu-level premium feature by tier + slot budget. +// Returns true when the caller should proceed (open its modal). +function canUsePremiumFeature(settingId) { + const tier = HTML.getAttribute('tier'); + if (tier === TIER.PREMIUM) return true; + if (cache[settingId] === true) return true; // already active — managing is fine + if (tier === TIER.FREE_SIGNED_IN && countActivePremium(cache) < PREMIUM_CONFIG.FREE_PREMIUM_SLOTS) { + return true; } - // Signed in but not premium - open upgrade modal - recordEvent('Premium Feature Click', { signedIn: true }); - openUpgradeModal(); + if (tier === TIER.FREE_SIGNED_IN) openUpgradeModal({ reason: 'slot_limit' }); + else openPremiumRequiredModal(); + return false; } // Premium required modal (for non-signed-in users) diff --git a/src/shared/config.js b/src/shared/config.js index 9c93279..3e1173f 100644 --- a/src/shared/config.js +++ b/src/shared/config.js @@ -10,6 +10,9 @@ const PREMIUM_CONFIG = { // License token refresh threshold (refresh if expiring within this window) LICENSE_REFRESH_THRESHOLD_MS: 24 * 60 * 60 * 1000, // 24 hours + // Number of premium features a signed-in non-premium user can activate for free + FREE_PREMIUM_SLOTS: 2, + // Storage keys STORAGE_KEYS: { SESSION_TOKEN: 'session_token', @@ -17,3 +20,10 @@ const PREMIUM_CONFIG = { USER_EMAIL: 'user_email', }, }; + +// Tier enum for License.getTierSync() and the html[tier] attribute. +const TIER = Object.freeze({ + PREMIUM: 'premium', + FREE_SIGNED_IN: 'free_signed_in', + FREE: 'free', +}); diff --git a/src/shared/license.js b/src/shared/license.js index 7fc2f0e..1166f5b 100644 --- a/src/shared/license.js +++ b/src/shared/license.js @@ -117,6 +117,12 @@ const License = { return decoded.premium === true && decoded.exp > now; }, + getTierSync(licenseToken, sessionToken) { + if (this.isPremiumSync(licenseToken)) return TIER.PREMIUM; + if (sessionToken) return TIER.FREE_SIGNED_IN; + return TIER.FREE; + }, + // Create checkout session for subscription async createCheckoutSession(plan) { const token = await Auth.getSessionToken(); diff --git a/src/shared/main.js b/src/shared/main.js index 85dda8d..1dbe018 100644 --- a/src/shared/main.js +++ b/src/shared/main.js @@ -842,3 +842,47 @@ function sectionNameToUrl(name) { return HOST + 'features/' + sectionPath + '/'; } + + +// Premium features in SECTIONS plus menu-level premium toggles (schedule, password). +// Together these form the pool of features a slot-budgeted free user can activate. +const PREMIUM_FEATURE_IDS = [ + ...SECTIONS.flatMap(section => + section.options.filter(opt => opt.premium).map(opt => opt.id) + ), + 'schedule', + 'password', +]; +const PREMIUM_FEATURE_ID_SET = new Set(PREMIUM_FEATURE_IDS); + +function countActivePremium(cache) { + let n = 0; + PREMIUM_FEATURE_IDS.forEach(id => { if (cache[id] === true) n++; }); + return n; +} + +// Mutates `settings` to force every premium feature off. In-memory only — +// does not return a write-back map because callers intentionally leave stored +// values alone so the user's premium state returns on re-upgrade. +function clearAllPremium(settings) { + PREMIUM_FEATURE_IDS.forEach(id => { settings[id] = false; }); +} + +// Mutates `settings` so at most `slotLimit` premium features stay true (by +// iteration order). Returns a write-back map of the cleared settings — callers +// should persist these to storage so over-budget premium settings don't +// re-surface on the next load. +function enforceSlotBudget(settings, slotLimit) { + const writeBack = {}; + let kept = 0; + PREMIUM_FEATURE_IDS.forEach(id => { + if (settings[id] !== true) return; + if (kept < slotLimit) { + kept++; + } else { + settings[id] = false; + writeBack[id] = false; + } + }); + return writeBack; +}