version 4.3.79

This commit is contained in:
Lawrence Hook
2026-04-19 20:21:08 -04:00
parent 0230a73aaa
commit a289d04a69
12 changed files with 378 additions and 143 deletions
+7 -4
View File
@@ -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
+2 -1
View File
@@ -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",
+29 -18
View File
@@ -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 ]) => {
+2 -1
View File
@@ -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",
+86 -35
View File
@@ -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;
}
+17
View File
@@ -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;
+10 -1
View File
@@ -190,7 +190,10 @@
<span id="premium_required_header_premium">Premium</span>
</div>
</div>
<p id="premium_required_description">Premium required. Sign in to upgrade.</p>
<p id="premium_required_description">
<span class="premium-note-main">Sign in to enable any 2 premium features.</span>
<span class="premium-note-sub">Upgrade to unlock them all.</span>
</p>
<div id="premium_required_buttons">
<button id="premium-required-cancel-button">Cancel</button>
<button id="premium-required-signin-button">Sign In</button>
@@ -208,6 +211,10 @@
</div>
<span id="upgrade_header_action">Upgrade</span>
</div>
<div id="upgrade_slot_limit_note" hidden>
<span class="premium-note-main">Free tier allows 2 premium features.</span>
<span class="premium-note-sub">Upgrade or deselect one.</span>
</div>
<div id="upgrade_plans">
<div class="upgrade-plan" data-plan="monthly">
<div class="plan-name">Monthly</div>
@@ -292,6 +299,8 @@
<input type=search placeholder='Search' autofocus tabindex="0"></input>
</div>
<div id='header-slot-indicator' hidden title='Free premium features used'></div>
<a title='On/Off' id="power-icon">
<div id='power-pop-up'>Button is disabled because the schedule is active.</div>
<svg id="header-toggle" class='global_enable header-toggle' xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
+88 -45
View File
@@ -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 = '<span>active</span><span class="active_count"></span>';
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);
}
+77 -38
View File
@@ -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)
+10
View File
@@ -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',
});
+6
View File
@@ -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();
+44
View File
@@ -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;
}