diff --git a/features/dating-autopilot/extensions/firefox-tryst/content/content.js b/features/dating-autopilot/extensions/firefox-tryst/content/content.js index 797fc1770..7f4aa32a0 100644 --- a/features/dating-autopilot/extensions/firefox-tryst/content/content.js +++ b/features/dating-autopilot/extensions/firefox-tryst/content/content.js @@ -1,30 +1,35 @@ // Content script - runs on app.tryst.link/members/providers* pages -// Reads boost state from Svelte component props and clicks buttons on command +// Handles all timing, state reading, and button clicking directly. +// No polling — uses setTimeout scheduled to the exact refresh window. (function () { 'use strict'; - /** - * Parse the Svelte component props from the .provider-available-now element. - * Returns { availableNow, availableUntil, availableNowUsableAt, availableNowCooldown, plan, visible } - */ + const BOOST_DURATION_MS = 4 * 60 * 60 * 1000; // 4 hours + const REFRESH_WINDOW_MIN_MS = 3 * 60 * 60 * 1000; // earliest: 3 hours + const REFRESH_WINDOW_MAX_MS = 3.5 * 60 * 60 * 1000; // latest: 3.5 hours + const POST_CLICK_DELAY_MS = 5000; // wait after clicking before re-reading state + const COOLDOWN_POLL_MS = 30000; // if stuck in cooldown, re-check every 30s + + let enabled = true; + let refreshTimer = null; + + // ============== STATE READING ============== + function readBoostState() { - // Look for the toggle component by its Svelte hydration target const container = document.querySelector('.provider-available-now'); if (!container) return null; - // Svelte SSR components embed props in a script tag or data attribute - // Try data-svelte-props first (server-rendered) + // Try structured data first (Svelte SSR props) const propsAttr = container.getAttribute('data-svelte-props'); if (propsAttr) { try { return JSON.parse(propsAttr); } catch { - // Fall through to DOM parsing + // Fall through } } - // Try finding a script[type="application/json"] inside the component const jsonScript = container.querySelector('script[type="application/json"]'); if (jsonScript) { try { @@ -34,132 +39,73 @@ } } - // Fallback: infer state from DOM text and button state return inferStateFromDOM(container); } - /** - * Fallback: read boost state from visible DOM elements when structured data isn't available. - */ function inferStateFromDOM(container) { const text = container.textContent || ''; const btn = findActionButton(); + const btnText = btn ? btn.textContent.trim().toLowerCase() : ''; const state = { availableNow: false, availableUntil: null, availableNowUsableAt: null, availableNowCooldown: false, - plan: null, - visible: true, }; - if (!btn) return state; - - const btnText = btn.textContent.trim().toLowerCase(); - if (btnText.includes('turn off')) { - // Boost is active state.availableNow = true; - - // Try to parse "Boost ends X hours and Y minutes from now" - const timeMatch = text.match( - /(\d+)\s*hours?\s*and\s*(\d+)\s*minutes?\s*from\s*now/i, - ); + const timeMatch = text.match(/(\d+)\s*hours?\s*and\s*(\d+)\s*minutes?\s*from\s*now/i); if (timeMatch) { - const hours = parseInt(timeMatch[1], 10); - const minutes = parseInt(timeMatch[2], 10); - const msRemaining = (hours * 60 + minutes) * 60 * 1000; - state.availableUntil = new Date(Date.now() + msRemaining).toISOString(); + const ms = (parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10)) * 60000; + state.availableUntil = new Date(Date.now() + ms).toISOString(); } - } else if (btnText.includes('mark as available')) { - state.availableNow = false; } - // Check for cooldown text - if (text.includes('reactivate in') || text.includes('cooldown')) { + if (text.includes('reactivate in') || text.includes('cooldown') || (btn && btn.disabled)) { state.availableNowCooldown = true; - - const cooldownMatch = text.match( - /reactivate\s+in\s+(\d+)\s*hours?\s*and\s*(\d+)\s*minutes?/i, - ); + const cooldownMatch = text.match(/reactivate\s+in\s+(\d+)\s*hours?\s*and\s*(\d+)\s*minutes?/i); if (cooldownMatch) { - const hours = parseInt(cooldownMatch[1], 10); - const minutes = parseInt(cooldownMatch[2], 10); - const msRemaining = (hours * 60 + minutes) * 60 * 1000; - state.availableNowUsableAt = new Date( - Date.now() + msRemaining, - ).toISOString(); + const ms = (parseInt(cooldownMatch[1], 10) * 60 + parseInt(cooldownMatch[2], 10)) * 60000; + state.availableNowUsableAt = new Date(Date.now() + ms).toISOString(); } } - // Check for disabled button (cooldown without text) - if (btn.disabled) { - state.availableNowCooldown = true; - } - return state; } - /** - * Find the actionable button: either "Mark as available" or "Turn off" - */ function findActionButton() { - // Primary: look for buttons with specific text const buttons = document.querySelectorAll( '.provider-available-now button, .provider-available-now .btn', ); for (const btn of buttons) { const text = btn.textContent.trim().toLowerCase(); - if ( - text.includes('mark as available') || - text.includes('turn off') || - text.includes('available now') - ) { + if (text.includes('mark as available') || text.includes('turn off') || text.includes('available now')) { return btn; } } - - // Secondary: look for any button in the boost container const container = document.querySelector('.provider-available-now'); if (container) { return container.querySelector('button') || container.querySelector('.btn'); } - return null; } - /** - * Click the boost button. No mouse simulation needed — this is the user's own account panel. - */ - function clickButton(btn) { - if (!btn) return false; - btn.click(); - return true; + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); } - /** - * Calculate minutes remaining on current boost from availableUntil timestamp. - */ - function getBoostTimeRemaining(availableUntil) { - if (!availableUntil) return null; - const until = new Date(availableUntil).getTime(); - const now = Date.now(); - const remaining = until - now; - return remaining > 0 ? Math.round(remaining / 60000) : 0; + function randomInRange(minMs, maxMs) { + return minMs + Math.random() * (maxMs - minMs); } - /** - * Wait for a DOM change after clicking a button (state transition). - * Returns true if the container text changed within the timeout. - */ + // ============== DOM CHANGE DETECTION ============== + function waitForStateChange(timeoutMs = 10000) { return new Promise((resolve) => { const container = document.querySelector('.provider-available-now'); - if (!container) { - resolve(false); - return; - } + if (!container) { resolve(false); return; } const originalText = container.textContent; let resolved = false; @@ -172,143 +118,246 @@ } }); - observer.observe(container, { - childList: true, - subtree: true, - characterData: true, - }); + observer.observe(container, { childList: true, subtree: true, characterData: true }); setTimeout(() => { - if (!resolved) { - resolved = true; - observer.disconnect(); - resolve(false); - } + if (!resolved) { resolved = true; observer.disconnect(); resolve(false); } }, timeoutMs); }); } - // ============== MESSAGE HANDLER ============== - browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg.action === 'checkState') { - const state = readBoostState(); - if (state) { - const minutesRemaining = getBoostTimeRemaining(state.availableUntil); - sendResponse({ - ...state, - minutesRemaining, - buttonFound: !!findActionButton(), - timestamp: Date.now(), - }); - } else { - sendResponse({ - error: 'boost_container_not_found', - message: 'Could not find .provider-available-now element on page', - timestamp: Date.now(), - }); - } - return true; + // ============== BACKGROUND REPORTING ============== + + function report(action, data = {}) { + browser.runtime.sendMessage({ action, ...data }).catch(() => {}); + } + + // ============== CORE CYCLE ============== + + function clearRefreshTimer() { + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + } + + function scheduleRefresh(boostActivatedAt) { + clearRefreshTimer(); + + // Pick a random time between 3h and 3.5h after activation + const delayMs = randomInRange(REFRESH_WINDOW_MIN_MS, REFRESH_WINDOW_MAX_MS); + const elapsed = Date.now() - new Date(boostActivatedAt).getTime(); + const remaining = Math.max(0, delayMs - elapsed); + + const fireAt = new Date(Date.now() + remaining); + console.log(`⚡ Refresh scheduled for ${fireAt.toLocaleTimeString()} (${Math.round(remaining / 60000)}min from now)`); + + report('boostStateUpdate', { + status: 'monitoring', + boostActive: true, + boostExpiresAt: new Date(new Date(boostActivatedAt).getTime() + BOOST_DURATION_MS).toISOString(), + nextRefreshAt: fireAt.toISOString(), + }); + + refreshTimer = setTimeout(() => executeRefreshCycle(), remaining); + } + + async function executeRefreshCycle() { + if (!enabled) return; + + console.log('↻ Executing refresh cycle...'); + report('boostStateUpdate', { status: 'refreshing', boostActive: true }); + + const preState = readBoostState(); + if (!preState?.availableNow) { + // Boost already off or expired — just activate + console.log('⚡ Boost already inactive, activating directly'); + await activateBoost(); + return; } - if (msg.action === 'turnOff') { - const state = readBoostState(); - if (!state?.availableNow) { - sendResponse({ success: false, reason: 'not_active' }); - return true; - } - - const btn = findActionButton(); - if (!btn) { - sendResponse({ success: false, reason: 'button_not_found' }); - return true; - } - - const clicked = clickButton(btn); - if (clicked) { - // Wait for DOM to update, then re-read state - waitForStateChange(8000).then((changed) => { - const newState = readBoostState(); - sendResponse({ - success: true, - changed, - newState, - timestamp: Date.now(), - }); - }); - } else { - sendResponse({ success: false, reason: 'click_failed' }); - } - return true; // Keep message channel open for async response + // Step 1: Turn off + const turnedOffAt = new Date().toISOString(); + const btn = findActionButton(); + if (!btn) { + report('cycleError', { errorType: 'button_not_found', error: 'Turn-off button not found' }); + return; } - if (msg.action === 'turnOn') { - const state = readBoostState(); - if (state?.availableNow) { - sendResponse({ success: false, reason: 'already_active' }); - return true; - } - if (state?.availableNowCooldown) { - sendResponse({ - success: false, - reason: 'cooldown_active', - usableAt: state.availableNowUsableAt, - }); - return true; - } + btn.click(); + console.log('⚡ Clicked turn-off, waiting for state change...'); + await waitForStateChange(8000); + await sleep(POST_CLICK_DELAY_MS); - const btn = findActionButton(); - if (!btn) { - sendResponse({ success: false, reason: 'button_not_found' }); - return true; - } + // Step 2: Read post-off state and handle cooldown + const postState = readBoostState(); - if (btn.disabled) { - sendResponse({ success: false, reason: 'button_disabled' }); - return true; - } - - const clicked = clickButton(btn); - if (clicked) { - waitForStateChange(8000).then((changed) => { - const newState = readBoostState(); - sendResponse({ - success: true, - changed, - newState, - timestamp: Date.now(), - }); - }); - } else { - sendResponse({ success: false, reason: 'click_failed' }); - } - return true; + if (postState?.availableNowCooldown) { + console.log('⏳ Cooldown active after turn-off, waiting...'); + report('boostStateUpdate', { status: 'cooldown', boostActive: false }); + await waitForCooldownEnd(); } - if (msg.action === 'getState') { - const state = readBoostState(); - const btn = findActionButton(); - sendResponse({ - boostState: state, - buttonFound: !!btn, - buttonText: btn?.textContent?.trim() || null, - buttonDisabled: btn?.disabled || false, - pageUrl: window.location.href, - timestamp: Date.now(), - }); - return true; + // Step 3: Reactivate + const reactivatedAt = await activateBoost(); + if (!reactivatedAt) return; // activateBoost reports errors + + const newState = readBoostState(); + const boostExpiresAt = newState?.availableUntil || new Date(Date.now() + BOOST_DURATION_MS).toISOString(); + const nextRefreshAt = new Date(Date.now() + randomInRange(REFRESH_WINDOW_MIN_MS, REFRESH_WINDOW_MAX_MS)).toISOString(); + + report('cycleComplete', { + turnedOffAt, + reactivatedAt, + cooldownDuration: new Date(reactivatedAt).getTime() - new Date(turnedOffAt).getTime(), + boostExpiresAt, + nextRefreshAt, + }); + + // Schedule next cycle from NOW (fresh 4hr boost just started) + scheduleRefresh(reactivatedAt); + } + + async function activateBoost() { + const state = readBoostState(); + if (state?.availableNow) { + console.log('⚡ Boost already active'); + return new Date().toISOString(); } - }); + + if (state?.availableNowCooldown) { + await waitForCooldownEnd(); + } + + const btn = findActionButton(); + if (!btn) { + report('cycleError', { errorType: 'button_not_found', error: 'Activation button not found' }); + return null; + } + + if (btn.disabled) { + console.log('⏳ Button disabled, waiting for cooldown...'); + await waitForCooldownEnd(); + return activateBoost(); // Retry after cooldown + } + + btn.click(); + console.log('⚡ Clicked activate, waiting for state change...'); + await waitForStateChange(8000); + await sleep(POST_CLICK_DELAY_MS); + + const postState = readBoostState(); + if (!postState?.availableNow) { + report('cycleError', { errorType: 'activation_failed', error: 'Boost did not activate after click' }); + return null; + } + + const activatedAt = new Date().toISOString(); + console.log('✓ Boost activated at', activatedAt); + return activatedAt; + } + + async function waitForCooldownEnd() { + report('boostStateUpdate', { status: 'cooldown', boostActive: false }); + + while (enabled) { + const state = readBoostState(); + if (!state?.availableNowCooldown) { + console.log('✓ Cooldown ended'); + return; + } + + // If we know when cooldown ends, sleep until then + if (state.availableNowUsableAt) { + const waitMs = new Date(state.availableNowUsableAt).getTime() - Date.now(); + if (waitMs > 0) { + console.log(`⏳ Cooldown ends in ${Math.round(waitMs / 60000)}min, sleeping...`); + await sleep(Math.min(waitMs + 2000, COOLDOWN_POLL_MS)); + continue; + } + } + + // Unknown duration or timer expired but DOM hasn't updated — poll + await sleep(COOLDOWN_POLL_MS); + } + } // ============== INITIALIZATION ============== - console.log( - '⚡ Tryst Auto-Boost content script loaded on', - window.location.href, - ); - // Notify background that content script is ready - browser.runtime.sendMessage({ - action: 'contentReady', - url: window.location.href, - timestamp: Date.now(), + async function init() { + console.log('⚡ Tryst Auto-Boost content script loaded'); + + const stored = await browser.storage.local.get('boostState'); + enabled = stored.boostState?.enabled !== false; + + if (!enabled) { + console.log('⚡ Auto-boost disabled'); + return; + } + + // Read current state and decide what to do + // Small delay to let page hydrate + await sleep(2000); + + const state = readBoostState(); + if (!state) { + console.log('⚡ Boost container not found on this page'); + report('boostStateUpdate', { status: 'error', error: 'Boost container not found' }); + return; + } + + if (state.availableNow) { + // Boost is active — schedule refresh based on expiry + const activatedAt = state.availableUntil + ? new Date(new Date(state.availableUntil).getTime() - BOOST_DURATION_MS).toISOString() + : new Date().toISOString(); + console.log('⚡ Boost already active, scheduling refresh'); + scheduleRefresh(activatedAt); + + } else if (state.availableNowCooldown) { + // In cooldown — wait then activate + console.log('⏳ In cooldown on load, waiting...'); + await waitForCooldownEnd(); + const activatedAt = await activateBoost(); + if (activatedAt) scheduleRefresh(activatedAt); + + } else { + // Inactive, no cooldown — activate now + console.log('⚡ Boost inactive, activating...'); + const activatedAt = await activateBoost(); + if (activatedAt) scheduleRefresh(activatedAt); + } + } + + // ============== MESSAGE HANDLER (from background/popup) ============== + + browser.runtime.onMessage.addListener((msg) => { + if (msg.action === 'setEnabled') { + enabled = msg.enabled; + if (!enabled) { + clearRefreshTimer(); + console.log('⚡ Auto-boost disabled'); + } else { + console.log('⚡ Auto-boost enabled, reinitializing...'); + init(); + } + } + + if (msg.action === 'refreshNow') { + clearRefreshTimer(); + executeRefreshCycle(); + } + + if (msg.action === 'checkState') { + // Direct state query (for popup debugging) + const state = readBoostState(); + return Promise.resolve({ + ...state, + enabled, + timerActive: refreshTimer !== null, + }); + } }); + + init(); })();