diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f91904c62f..3b5562478a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1368,5 +1368,49 @@ "autoReadAloudChatGPT": { "message": "Auto Read Aloud (ChatGPT)", "description": "Label for the checkbox to enable/disable automatic reading of ChatGPT responses during voice calls." + }, + "authPromptToast": { + "message": "Sign in to SayPi for higher limits & premium features", + "description": "Toast notification encouraging user to sign in." + }, + "authPromptTitle": { + "message": "Unlock More Voice Features", + "description": "Title for the auth prompt modal." + }, + "authPromptModalSoft": { + "message": "Create a free SayPi account for higher usage limits and access to premium voice features.", + "description": "Description for the soft modal auth prompt." + }, + "authPromptModalFull": { + "message": "You're getting great use from SayPi! Sign in for higher limits, text-to-speech voices, and premium features across all supported chatbots.", + "description": "Description for the full modal auth prompt with value proposition." + }, + "authBenefitUsage": { + "message": "Track your voice usage across devices", + "description": "Benefit listed in auth modal - usage tracking." + }, + "authBenefitSync": { + "message": "Sync voice settings everywhere", + "description": "Benefit listed in auth modal - settings sync." + }, + "authBenefitSupport": { + "message": "Priority support for issues", + "description": "Benefit listed in auth modal - priority support." + }, + "authBenefitLimits": { + "message": "Higher usage limits with free account", + "description": "Benefit listed in auth modal - increased usage limits." + }, + "authBenefitTTS": { + "message": "Text-to-speech voices (paid plans)", + "description": "Benefit listed in auth modal - TTS feature." + }, + "authBenefitPremium": { + "message": "Premium features & chatbot support", + "description": "Benefit listed in auth modal - premium features across chatbots." + }, + "maybeLater": { + "message": "Maybe Later", + "description": "Button text to dismiss auth prompt." } } \ No newline at end of file diff --git a/src/auth/AuthPromptController.ts b/src/auth/AuthPromptController.ts new file mode 100644 index 0000000000..0fb0a0f828 --- /dev/null +++ b/src/auth/AuthPromptController.ts @@ -0,0 +1,343 @@ +/** + * AuthPromptController - Decides when and what authentication prompts to show + * + * Progressive prompting strategy: + * - After 5 transcriptions: Toast notification (non-blocking) + * - After 10 transcriptions: Soft modal (dismissible) + * - After 20 transcriptions: Modal with value proposition + * + * Respects: + * - User dismissals (backs off after repeated dismissals) + * - Cooldown periods between prompts + * - Authentication state (no prompts for authenticated users) + */ + +import EventBus from "../events/EventBus"; +import { logger } from "../LoggingModule"; +import { UsageTracker, getUsageTracker } from "./UsageTracker"; + +// Cross-browser runtime API +const browserAPI = typeof browser !== "undefined" ? browser : chrome; + +/** + * Prompt levels from least to most intrusive + */ +export type PromptLevel = "toast" | "soft-modal" | "modal"; + +/** + * Prompt configuration thresholds + */ +export interface PromptThresholds { + /** Transcriptions before showing toast */ + toast: number; + /** Transcriptions before showing soft modal */ + softModal: number; + /** Transcriptions before showing full modal */ + modal: number; +} + +/** + * Default thresholds - can be overridden via config + */ +const DEFAULT_THRESHOLDS: PromptThresholds = { + toast: 5, + softModal: 10, + modal: 20, +}; + +/** + * Cooldown between prompts (in milliseconds) + * Increases with each dismissal + */ +const BASE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes +const MAX_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Maximum dismissals before stopping prompts entirely + */ +const MAX_DISMISSALS = 5; + +/** + * AuthPromptController singleton + */ +export class AuthPromptController { + private static instance: AuthPromptController; + private usageTracker: UsageTracker; + private thresholds: PromptThresholds; + private isInitialized: boolean = false; + private isListening: boolean = false; + private cachedAuthStatus: boolean | null = null; + + private constructor(thresholds?: Partial) { + this.usageTracker = getUsageTracker(); + this.thresholds = { ...DEFAULT_THRESHOLDS, ...thresholds }; + logger.debug("[AuthPromptController] Created with thresholds:", this.thresholds); + } + + public static getInstance(thresholds?: Partial): AuthPromptController { + if (!AuthPromptController.instance) { + AuthPromptController.instance = new AuthPromptController(thresholds); + } + return AuthPromptController.instance; + } + + /** + * Initialize the controller + */ + public async initialize(): Promise { + if (this.isInitialized) { + logger.debug("[AuthPromptController] Already initialized"); + return; + } + + // Initialize usage tracker first + await this.usageTracker.initialize(); + + // Check initial auth status + await this.refreshAuthStatus(); + + // Set up event listeners + this.setupEventListeners(); + + this.isInitialized = true; + logger.debug("[AuthPromptController] Initialized"); + } + + /** + * Set up event listeners for usage updates and auth changes + */ + private setupEventListeners(): void { + if (this.isListening) { + return; + } + + // Listen for usage updates from UsageTracker + EventBus.on("saypi:usage:updated", this.handleUsageUpdate); + + // Listen for auth status changes + browserAPI.runtime.onMessage.addListener(this.handleMessage); + + this.isListening = true; + logger.debug("[AuthPromptController] Event listeners set up"); + } + + /** + * Handle messages from background script (auth status changes) + */ + private handleMessage = ( + message: { type: string; isAuthenticated?: boolean }, + _sender: chrome.runtime.MessageSender, + _sendResponse: (response?: unknown) => void + ): boolean | void => { + if (message.type === "AUTH_STATUS_CHANGED") { + logger.debug("[AuthPromptController] Auth status changed:", message.isAuthenticated); + this.cachedAuthStatus = message.isAuthenticated ?? false; + + // If user just authenticated, hide any visible prompts + if (this.cachedAuthStatus) { + EventBus.emit("saypi:authPrompt:hide"); + } + } + }; + + /** + * Refresh auth status from background script + */ + private async refreshAuthStatus(): Promise { + try { + const response = await browserAPI.runtime.sendMessage({ type: "GET_AUTH_STATUS" }); + this.cachedAuthStatus = response?.isAuthenticated ?? false; + logger.debug("[AuthPromptController] Auth status refreshed:", this.cachedAuthStatus); + return this.cachedAuthStatus; + } catch (error) { + logger.debug("[AuthPromptController] Failed to get auth status:", error); + this.cachedAuthStatus = false; + return false; + } + } + + /** + * Handle usage update events from UsageTracker + */ + private handleUsageUpdate = async (data: { + transcriptionCount: number; + firstUseDate: number; + }): Promise => { + logger.debug("[AuthPromptController] Usage update:", data); + + // Don't prompt if user is authenticated + if (this.cachedAuthStatus) { + logger.debug("[AuthPromptController] User is authenticated, skipping prompt check"); + return; + } + + // Refresh auth status in case it changed + const isAuthenticated = await this.refreshAuthStatus(); + if (isAuthenticated) { + logger.debug("[AuthPromptController] User is now authenticated, skipping prompt"); + return; + } + + // Check if we should show a prompt + const promptLevel = this.determinePromptLevel(data.transcriptionCount); + if (promptLevel) { + await this.maybeShowPrompt(promptLevel, data.transcriptionCount); + } + }; + + /** + * Determine what prompt level to show based on transcription count + */ + private determinePromptLevel(transcriptionCount: number): PromptLevel | null { + const stats = this.usageTracker.getStats(); + const lastLevel = stats.lastPromptLevel as PromptLevel | undefined; + + // Check thresholds in order from most to least intrusive + // Only upgrade if we've crossed a new threshold + if (transcriptionCount >= this.thresholds.modal) { + if (lastLevel !== "modal") { + return "modal"; + } + } else if (transcriptionCount >= this.thresholds.softModal) { + if (lastLevel !== "soft-modal" && lastLevel !== "modal") { + return "soft-modal"; + } + } else if (transcriptionCount >= this.thresholds.toast) { + if (!lastLevel) { + return "toast"; + } + } + + return null; + } + + /** + * Show a prompt if cooldown has passed and user hasn't dismissed too many times + */ + private async maybeShowPrompt( + level: PromptLevel, + transcriptionCount: number + ): Promise { + const stats = this.usageTracker.getStats(); + + // Check if user has dismissed too many times + if (stats.promptsDismissed >= MAX_DISMISSALS) { + logger.debug( + `[AuthPromptController] User dismissed ${stats.promptsDismissed} times, not showing more prompts` + ); + return; + } + + // Calculate cooldown based on number of dismissals + const cooldown = Math.min( + BASE_COOLDOWN_MS * Math.pow(2, stats.promptsDismissed), + MAX_COOLDOWN_MS + ); + + // Check if cooldown has passed + if (!this.usageTracker.hasPromptCooldownPassed(cooldown)) { + logger.debug("[AuthPromptController] Cooldown not passed, skipping prompt"); + return; + } + + // Emit event to show the prompt + logger.debug(`[AuthPromptController] Showing ${level} prompt`); + EventBus.emit("saypi:authPrompt:show", { + level, + transcriptionCount, + dismissedCount: stats.promptsDismissed, + }); + + // Record that we showed a prompt + await this.usageTracker.recordPromptShown(level); + } + + /** + * Called when user dismisses a prompt + */ + public async handlePromptDismissed(): Promise { + await this.usageTracker.recordPromptDismissed(); + EventBus.emit("saypi:authPrompt:hide"); + logger.debug("[AuthPromptController] Prompt dismissed"); + } + + /** + * Called when user clicks sign in from a prompt + */ + public handleSignInClicked(): void { + // Open the extension's settings page (reuses existing window if open) + browserAPI.runtime.sendMessage({ action: "openPopup" }).catch(() => { + // Fallback: open settings directly if message fails + const settingsUrl = browserAPI.runtime.getURL("settings.html"); + browserAPI.tabs.create({ url: settingsUrl }); + }); + + // Hide the prompt + EventBus.emit("saypi:authPrompt:hide"); + logger.debug("[AuthPromptController] Sign in clicked, opening settings"); + } + + /** + * Check if prompts are enabled (not maxed out on dismissals) + */ + public arePromptsEnabled(): boolean { + const stats = this.usageTracker.getStats(); + return stats.promptsDismissed < MAX_DISMISSALS; + } + + /** + * Get current thresholds + */ + public getThresholds(): PromptThresholds { + return { ...this.thresholds }; + } + + /** + * Update thresholds (for A/B testing or configuration) + */ + public setThresholds(thresholds: Partial): void { + this.thresholds = { ...this.thresholds, ...thresholds }; + logger.debug("[AuthPromptController] Thresholds updated:", this.thresholds); + } + + /** + * Force show a specific prompt level (for testing) + */ + public async forceShowPrompt(level: PromptLevel): Promise { + const stats = this.usageTracker.getStats(); + EventBus.emit("saypi:authPrompt:show", { + level, + transcriptionCount: stats.transcriptionCount, + dismissedCount: stats.promptsDismissed, + }); + await this.usageTracker.recordPromptShown(level); + } + + /** + * Reset the controller state (for testing) + */ + public async reset(): Promise { + await this.usageTracker.resetStats(); + this.cachedAuthStatus = null; + logger.debug("[AuthPromptController] Reset"); + } + + /** + * Clean up event listeners + */ + public cleanup(): void { + if (this.isListening) { + EventBus.off("saypi:usage:updated", this.handleUsageUpdate); + browserAPI.runtime.onMessage.removeListener(this.handleMessage); + this.isListening = false; + } + this.usageTracker.cleanup(); + this.isInitialized = false; + logger.debug("[AuthPromptController] Cleaned up"); + } +} + +// Export singleton instance getter +export const getAuthPromptController = ( + thresholds?: Partial +): AuthPromptController => AuthPromptController.getInstance(thresholds); diff --git a/src/auth/AuthPromptUI.ts b/src/auth/AuthPromptUI.ts new file mode 100644 index 0000000000..ba8afdbd38 --- /dev/null +++ b/src/auth/AuthPromptUI.ts @@ -0,0 +1,484 @@ +/** + * AuthPromptUI - UI components for progressive authentication prompts + * + * Displays toast and modal prompts encouraging users to sign in. + * Follows the same patterns as AgentModeNoticeModule and CompatibilityNotificationUI. + * + * Features: + * - Toast: Non-blocking notification in corner + * - Soft Modal: Dismissible overlay, less intrusive + * - Modal: Full modal with value proposition + * - Dark mode support + * - Mobile responsive + * - Accessibility support + */ + +import EventBus from "../events/EventBus"; +import { logger } from "../LoggingModule"; +import getMessage from "../i18n"; +import { IconModule } from "../icons/IconModule"; +import { ChatbotService } from "../chatbots/ChatbotService"; +import type { Chatbot } from "../chatbots/Chatbot"; +import type { PromptLevel } from "./AuthPromptController"; +import { getAuthPromptController } from "./AuthPromptController"; + +/** + * Event data for showing auth prompts + */ +interface AuthPromptShowEvent { + level: PromptLevel; + transcriptionCount: number; + dismissedCount: number; +} + +/** + * AuthPromptUI singleton for managing auth prompt display + */ +export class AuthPromptUI { + private static instance: AuthPromptUI; + private currentPrompt: HTMLElement | null = null; + private currentOverlay: HTMLElement | null = null; + private isInitialized: boolean = false; + private cachedChatbot: Chatbot | null = null; + + private constructor() { + logger.debug("[AuthPromptUI] Created"); + } + + public static getInstance(): AuthPromptUI { + if (!AuthPromptUI.instance) { + AuthPromptUI.instance = new AuthPromptUI(); + } + return AuthPromptUI.instance; + } + + /** + * Initialize the UI component and start listening for prompt events + */ + public initialize(): void { + if (this.isInitialized) { + logger.debug("[AuthPromptUI] Already initialized"); + return; + } + + // Listen for show/hide events from AuthPromptController + EventBus.on("saypi:authPrompt:show", this.handleShowPrompt); + EventBus.on("saypi:authPrompt:hide", this.handleHidePrompt); + + this.isInitialized = true; + logger.debug("[AuthPromptUI] Initialized"); + } + + /** + * Get cached chatbot instance + */ + private async getChatbot(): Promise { + if (!this.cachedChatbot) { + this.cachedChatbot = await ChatbotService.getChatbot(); + } + return this.cachedChatbot; + } + + /** + * Handle show prompt event + */ + private handleShowPrompt = async (event: AuthPromptShowEvent): Promise => { + logger.debug("[AuthPromptUI] Show prompt:", event); + + // Hide any existing prompt first + await this.hidePrompt(); + + switch (event.level) { + case "toast": + await this.showToast(event); + break; + case "soft-modal": + await this.showSoftModal(event); + break; + case "modal": + await this.showModal(event); + break; + } + }; + + /** + * Handle hide prompt event + */ + private handleHidePrompt = async (): Promise => { + await this.hidePrompt(); + }; + + /** + * Show a toast notification (non-blocking, bottom corner) + */ + private async showToast(event: AuthPromptShowEvent): Promise { + const toast = document.createElement("div"); + toast.className = "saypi-auth-prompt saypi-auth-toast"; + toast.setAttribute("role", "alert"); + toast.setAttribute("aria-live", "polite"); + + const content = this.createToastContent(event); + toast.appendChild(content); + + document.body.appendChild(toast); + this.currentPrompt = toast; + + // Show with animation + requestAnimationFrame(() => { + toast.classList.add("visible"); + }); + + // Auto-dismiss after 20 seconds + setTimeout(() => { + if (this.currentPrompt === toast) { + this.hidePrompt(); + } + }, 20000); + + logger.debug("[AuthPromptUI] Toast shown"); + } + + /** + * Show a soft modal (dismissible overlay, less intrusive) + */ + private async showSoftModal(event: AuthPromptShowEvent): Promise { + // Create overlay + const overlay = document.createElement("div"); + overlay.className = "saypi-auth-overlay saypi-auth-overlay-soft"; + overlay.addEventListener("click", () => this.handleDismiss()); + + // Create modal + const modal = document.createElement("div"); + modal.className = "saypi-auth-prompt saypi-auth-modal saypi-auth-modal-soft"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-labelledby", "saypi-auth-title"); + modal.addEventListener("click", (e) => e.stopPropagation()); + + const content = this.createModalContent(event, "soft"); + modal.appendChild(content); + + document.body.appendChild(overlay); + document.body.appendChild(modal); + + this.currentOverlay = overlay; + this.currentPrompt = modal; + + // Show with animation + requestAnimationFrame(() => { + overlay.classList.add("visible"); + modal.classList.add("visible"); + }); + + logger.debug("[AuthPromptUI] Soft modal shown"); + } + + /** + * Show a full modal with value proposition + */ + private async showModal(event: AuthPromptShowEvent): Promise { + // Create overlay + const overlay = document.createElement("div"); + overlay.className = "saypi-auth-overlay"; + overlay.addEventListener("click", () => this.handleDismiss()); + + // Create modal + const modal = document.createElement("div"); + modal.className = "saypi-auth-prompt saypi-auth-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-labelledby", "saypi-auth-title"); + modal.addEventListener("click", (e) => e.stopPropagation()); + + const content = this.createModalContent(event, "full"); + modal.appendChild(content); + + document.body.appendChild(overlay); + document.body.appendChild(modal); + + this.currentOverlay = overlay; + this.currentPrompt = modal; + + // Show with animation + requestAnimationFrame(() => { + overlay.classList.add("visible"); + modal.classList.add("visible"); + }); + + // Trap focus in modal + this.trapFocus(modal); + + logger.debug("[AuthPromptUI] Modal shown"); + } + + /** + * Create toast content + */ + private createToastContent(event: AuthPromptShowEvent): HTMLElement { + const content = document.createElement("div"); + content.className = "saypi-auth-prompt-content"; + + // Icon - use SayPi bubble logo for brand recognition + const iconContainer = document.createElement("div"); + iconContainer.className = "saypi-auth-prompt-icon saypi-brand-icon"; + try { + const icon = IconModule.bubbleBw.cloneNode(true) as SVGElement; + icon.setAttribute("width", "24"); + icon.setAttribute("height", "24"); + iconContainer.appendChild(icon); + } catch { + iconContainer.textContent = "🗣️"; + } + content.appendChild(iconContainer); + + // Text + const textContainer = document.createElement("div"); + textContainer.className = "saypi-auth-prompt-text"; + textContainer.textContent = getMessage("authPromptToast") || + "Sign in to track your voice usage and sync settings"; + content.appendChild(textContainer); + + // Sign in button + const signInButton = document.createElement("button"); + signInButton.className = "saypi-auth-prompt-signin"; + signInButton.textContent = getMessage("signIn") || "Sign In"; + signInButton.addEventListener("click", () => this.handleSignIn()); + content.appendChild(signInButton); + + // Close button + const closeButton = document.createElement("button"); + closeButton.className = "saypi-auth-prompt-close"; + closeButton.setAttribute("aria-label", getMessage("dismissNotice") || "Dismiss"); + closeButton.innerHTML = "×"; + closeButton.addEventListener("click", () => this.handleDismiss()); + content.appendChild(closeButton); + + return content; + } + + /** + * Create modal content + */ + private createModalContent(event: AuthPromptShowEvent, variant: "soft" | "full"): HTMLElement { + const content = document.createElement("div"); + content.className = "saypi-auth-modal-content"; + + // Close button (top right) + const closeButton = document.createElement("button"); + closeButton.className = "saypi-auth-modal-close"; + closeButton.setAttribute("aria-label", getMessage("dismissNotice") || "Dismiss"); + closeButton.innerHTML = "×"; + closeButton.addEventListener("click", () => this.handleDismiss()); + content.appendChild(closeButton); + + // Brand header - SayPi identification + const brandHeader = document.createElement("div"); + brandHeader.className = "saypi-auth-modal-brand"; + brandHeader.textContent = "Say, Pi"; + content.appendChild(brandHeader); + + // Icon - use SayPi bubble logo for brand recognition + const iconContainer = document.createElement("div"); + iconContainer.className = "saypi-auth-modal-icon saypi-brand-icon"; + try { + const icon = IconModule.bubbleBw.cloneNode(true) as SVGElement; + icon.setAttribute("width", "56"); + icon.setAttribute("height", "56"); + iconContainer.appendChild(icon); + } catch { + iconContainer.textContent = "🗣️"; + } + content.appendChild(iconContainer); + + // Title + const title = document.createElement("h2"); + title.id = "saypi-auth-title"; + title.className = "saypi-auth-modal-title"; + title.textContent = getMessage("authPromptTitle") || + "Unlock More Voice Features"; + content.appendChild(title); + + // Description + const description = document.createElement("p"); + description.className = "saypi-auth-modal-description"; + + if (variant === "full") { + description.textContent = getMessage("authPromptModalFull") || + "You've used SayPi for " + event.transcriptionCount + " voice messages! Sign in to unlock usage tracking, sync your settings across devices, and get personalized voice features."; + } else { + description.textContent = getMessage("authPromptModalSoft") || + "Sign in to track your voice usage and sync your settings across all your devices."; + } + content.appendChild(description); + + // Benefits list (only for full modal) + if (variant === "full") { + const benefitsList = document.createElement("ul"); + benefitsList.className = "saypi-auth-modal-benefits"; + + const benefits = [ + getMessage("authBenefitLimits") || "Higher usage limits with free account", + getMessage("authBenefitTTS") || "Text-to-speech voices (paid plans)", + getMessage("authBenefitPremium") || "Premium features & chatbot support" + ]; + + benefits.forEach(benefit => { + const li = document.createElement("li"); + li.textContent = benefit; + benefitsList.appendChild(li); + }); + + content.appendChild(benefitsList); + } + + // Buttons container + const buttonsContainer = document.createElement("div"); + buttonsContainer.className = "saypi-auth-modal-buttons"; + + // Sign in button (primary) + const signInButton = document.createElement("button"); + signInButton.className = "saypi-auth-modal-signin"; + signInButton.textContent = getMessage("signIn") || "Sign In"; + signInButton.addEventListener("click", () => this.handleSignIn()); + buttonsContainer.appendChild(signInButton); + + // Maybe later button (secondary) + const laterButton = document.createElement("button"); + laterButton.className = "saypi-auth-modal-later"; + laterButton.textContent = getMessage("maybeLater") || "Maybe Later"; + laterButton.addEventListener("click", () => this.handleDismiss()); + buttonsContainer.appendChild(laterButton); + + content.appendChild(buttonsContainer); + + return content; + } + + /** + * Handle sign in button click + */ + private handleSignIn(): void { + getAuthPromptController().handleSignInClicked(); + } + + /** + * Handle dismiss (close button or overlay click) + */ + private async handleDismiss(): Promise { + await getAuthPromptController().handlePromptDismissed(); + } + + /** + * Hide the current prompt with animation + */ + private async hidePrompt(): Promise { + const promptToRemove = this.currentPrompt; + const overlayToRemove = this.currentOverlay; + + if (!promptToRemove && !overlayToRemove) { + return; + } + + // Start hide animation + if (promptToRemove) { + promptToRemove.classList.remove("visible"); + } + if (overlayToRemove) { + overlayToRemove.classList.remove("visible"); + } + + // Remove from DOM after animation + await new Promise(resolve => { + setTimeout(() => { + if (promptToRemove?.parentNode) { + promptToRemove.parentNode.removeChild(promptToRemove); + } + if (overlayToRemove?.parentNode) { + overlayToRemove.parentNode.removeChild(overlayToRemove); + } + resolve(); + }, 300); + }); + + this.currentPrompt = null; + this.currentOverlay = null; + + logger.debug("[AuthPromptUI] Prompt hidden"); + } + + /** + * Trap focus within modal for accessibility + */ + private trapFocus(modal: HTMLElement): void { + const focusableElements = modal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + // Focus first element + firstElement.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Tab") { + if (e.shiftKey && document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } else if (!e.shiftKey && document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } else if (e.key === "Escape") { + this.handleDismiss(); + } + }; + + modal.addEventListener("keydown", handleKeyDown); + + // Clean up when modal is removed + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.removedNodes.length > 0) { + mutation.removedNodes.forEach((node) => { + if (node === modal) { + modal.removeEventListener("keydown", handleKeyDown); + observer.disconnect(); + } + }); + } + }); + }); + + if (modal.parentNode) { + observer.observe(modal.parentNode, { childList: true }); + } + } + + /** + * Force show a prompt (for testing) + */ + public async forceShow(level: PromptLevel): Promise { + await this.handleShowPrompt({ + level, + transcriptionCount: 10, + dismissedCount: 0, + }); + } + + /** + * Clean up event listeners + */ + public cleanup(): void { + EventBus.off("saypi:authPrompt:show", this.handleShowPrompt); + EventBus.off("saypi:authPrompt:hide", this.handleHidePrompt); + this.hidePrompt(); + this.isInitialized = false; + logger.debug("[AuthPromptUI] Cleaned up"); + } +} + +// Export singleton getter +export const getAuthPromptUI = (): AuthPromptUI => AuthPromptUI.getInstance(); diff --git a/src/auth/UsageTracker.ts b/src/auth/UsageTracker.ts new file mode 100644 index 0000000000..de01ca27f8 --- /dev/null +++ b/src/auth/UsageTracker.ts @@ -0,0 +1,272 @@ +/** + * UsageTracker - Anonymous usage tracking for progressive authentication prompts + * + * Tracks voice interaction count and usage stats without identifying the user. + * Data is stored locally and used to determine when to show auth prompts. + * + * Features: + * - Tracks voice interaction count across sessions (conversation turns + dictation completions) + * - Stores first use date for engagement analysis + * - Tracks prompt interactions (shown, dismissed) + * - Uses browser.storage.local for persistence + * - Resets only on extension uninstall + * + * Note: We track high-level interactions (conversation turns, dictation completions) + * rather than individual transcription requests, since a single user message + * may require many transcription segments. + */ + +import EventBus from "../events/EventBus"; +import { logger } from "../LoggingModule"; + +// Cross-browser storage API +const browserAPI = typeof browser !== "undefined" ? browser : chrome; + +/** + * Usage statistics stored in browser.storage.local + */ +export interface UsageStats { + /** Total number of voice interactions (conversation turns + dictation completions) */ + interactionCount: number; + /** @deprecated Use interactionCount instead - kept for backwards compatibility */ + transcriptionCount: number; + /** Timestamp of first interaction (ms since epoch) */ + firstUseDate: number; + /** Timestamp of last auth prompt shown (ms since epoch) */ + lastPromptShown: number; + /** Number of times user dismissed an auth prompt */ + promptsDismissed: number; + /** Prompt level that was last shown (e.g., 'toast', 'soft-modal', 'modal') */ + lastPromptLevel?: string; +} + +/** + * Default initial stats for new users + */ +const DEFAULT_STATS: UsageStats = { + interactionCount: 0, + transcriptionCount: 0, // deprecated, kept for backwards compatibility + firstUseDate: 0, + lastPromptShown: 0, + promptsDismissed: 0, + lastPromptLevel: undefined, +}; + +const STORAGE_KEY = "saypi-usage-stats"; + +/** + * UsageTracker singleton for tracking anonymous usage + */ +export class UsageTracker { + private static instance: UsageTracker; + private stats: UsageStats = { ...DEFAULT_STATS }; + private isInitialized: boolean = false; + private isListening: boolean = false; + + private constructor() { + logger.debug("[UsageTracker] Created"); + } + + public static getInstance(): UsageTracker { + if (!UsageTracker.instance) { + UsageTracker.instance = new UsageTracker(); + } + return UsageTracker.instance; + } + + /** + * Initialize the tracker by loading stats from storage and setting up event listeners + */ + public async initialize(): Promise { + if (this.isInitialized) { + logger.debug("[UsageTracker] Already initialized"); + return; + } + + await this.loadStats(); + this.setupEventListeners(); + this.isInitialized = true; + + logger.debug("[UsageTracker] Initialized with stats:", this.stats); + } + + /** + * Load stats from browser storage + * Handles migration from old format (transcriptionCount only) to new format (interactionCount) + */ + private async loadStats(): Promise { + try { + const result = await browserAPI.storage.local.get(STORAGE_KEY); + if (result[STORAGE_KEY]) { + const loadedStats = { ...DEFAULT_STATS, ...result[STORAGE_KEY] }; + + // Migration: if interactionCount is 0 but transcriptionCount exists, use transcriptionCount + // This handles upgrading from old format where only transcriptionCount was tracked + if (loadedStats.interactionCount === 0 && loadedStats.transcriptionCount > 0) { + // Old data used transcriptionCount for raw transcription requests + // Since we're changing to track interactions (messages/dictations), + // we'll start fresh rather than carry over inflated counts + logger.debug( + `[UsageTracker] Migration: resetting count (was ${loadedStats.transcriptionCount} transcriptions)` + ); + loadedStats.interactionCount = 0; + loadedStats.transcriptionCount = 0; + } + + this.stats = loadedStats; + } + } catch (error) { + logger.error("[UsageTracker] Failed to load stats:", error); + } + } + + /** + * Save stats to browser storage + */ + private async saveStats(): Promise { + try { + await browserAPI.storage.local.set({ [STORAGE_KEY]: this.stats }); + } catch (error) { + logger.error("[UsageTracker] Failed to save stats:", error); + } + } + + /** + * Set up event listeners for voice interactions + * We track conversation turns (messages sent) and dictation completions + * rather than individual transcription segments + */ + private setupEventListeners(): void { + if (this.isListening) { + return; + } + + // Listen for conversation turn completions (message submitted to chatbot) + EventBus.on("session:message-sent", this.handleInteractionCompleted); + + // Listen for dictation completions (user finished dictating to a field) + EventBus.on("dictation:complete", this.handleInteractionCompleted); + + this.isListening = true; + + logger.debug("[UsageTracker] Event listeners set up for voice interactions"); + } + + /** + * Handle a completed voice interaction (conversation turn or dictation) + */ + private handleInteractionCompleted = async (): Promise => { + this.stats.interactionCount++; + // Also update deprecated field for backwards compatibility + this.stats.transcriptionCount = this.stats.interactionCount; + + // Set first use date if this is the first interaction + if (this.stats.firstUseDate === 0) { + this.stats.firstUseDate = Date.now(); + } + + await this.saveStats(); + + logger.debug( + `[UsageTracker] Voice interaction completed. Total count: ${this.stats.interactionCount}` + ); + + // Emit event for AuthPromptController to react to + EventBus.emit("saypi:usage:updated", { + transcriptionCount: this.stats.interactionCount, // Use interactionCount for compatibility + firstUseDate: this.stats.firstUseDate, + }); + }; + + /** + * Get current usage stats + */ + public getStats(): UsageStats { + return { ...this.stats }; + } + + /** + * Get the current voice interaction count + */ + public getInteractionCount(): number { + return this.stats.interactionCount; + } + + /** + * @deprecated Use getInteractionCount instead + */ + public getTranscriptionCount(): number { + return this.stats.interactionCount; + } + + /** + * Record that a prompt was shown + * @param level The prompt level (e.g., 'toast', 'soft-modal', 'modal') + */ + public async recordPromptShown(level: string): Promise { + this.stats.lastPromptShown = Date.now(); + this.stats.lastPromptLevel = level; + await this.saveStats(); + + logger.debug(`[UsageTracker] Recorded prompt shown: ${level}`); + } + + /** + * Record that a prompt was dismissed + */ + public async recordPromptDismissed(): Promise { + this.stats.promptsDismissed++; + await this.saveStats(); + + logger.debug( + `[UsageTracker] Recorded prompt dismissed. Total dismissed: ${this.stats.promptsDismissed}` + ); + } + + /** + * Check if enough time has passed since the last prompt + * @param cooldownMs Minimum time between prompts in milliseconds + */ + public hasPromptCooldownPassed(cooldownMs: number): boolean { + if (this.stats.lastPromptShown === 0) { + return true; + } + return Date.now() - this.stats.lastPromptShown > cooldownMs; + } + + /** + * Get time since first use in days + */ + public getDaysSinceFirstUse(): number { + if (this.stats.firstUseDate === 0) { + return 0; + } + const msPerDay = 24 * 60 * 60 * 1000; + return Math.floor((Date.now() - this.stats.firstUseDate) / msPerDay); + } + + /** + * Reset all stats (useful for testing) + */ + public async resetStats(): Promise { + this.stats = { ...DEFAULT_STATS }; + await this.saveStats(); + logger.debug("[UsageTracker] Stats reset"); + } + + /** + * Clean up event listeners + */ + public cleanup(): void { + if (this.isListening) { + EventBus.off("session:message-sent", this.handleInteractionCompleted); + EventBus.off("dictation:complete", this.handleInteractionCompleted); + this.isListening = false; + } + this.isInitialized = false; + logger.debug("[UsageTracker] Cleaned up"); + } +} + +// Export singleton instance getter +export const getUsageTracker = (): UsageTracker => UsageTracker.getInstance(); diff --git a/src/saypi.index.js b/src/saypi.index.js index 14d4165d4a..db289eb378 100644 --- a/src/saypi.index.js +++ b/src/saypi.index.js @@ -12,6 +12,7 @@ import "./styles/mobile.scss"; import "./styles/rectangles.css"; import "./styles/agent-notice.scss"; import "./styles/compat-notice.scss"; +import "./styles/auth-prompt.scss"; import { ChatbotService } from "./chatbots/ChatbotService.ts"; import { ChatbotIdentifier } from "./chatbots/ChatbotIdentifier.ts"; @@ -43,6 +44,20 @@ import "./styles/pi.scss"; // scoped by chatbot flags, i.e. const { CompatibilityNotificationUI } = await import(/* webpackMode: "eager" */ "./compat/CompatibilityNotificationUI.ts"); CompatibilityNotificationUI.getInstance().initialize(); + // Initialize progressive authentication prompt system (for chatbot overlay prompts) + logger.debug("Initializing auth prompt system"); + const { getAuthPromptController } = await import(/* webpackMode: "eager" */ "./auth/AuthPromptController.ts"); + const { getAuthPromptUI } = await import(/* webpackMode: "eager" */ "./auth/AuthPromptUI.ts"); + const { getUsageTracker } = await import(/* webpackMode: "eager" */ "./auth/UsageTracker.ts"); + await getAuthPromptController().initialize(); + getAuthPromptUI().initialize(); + + // Expose debug method for resetting auth prompt tracking + window.saypiResetAuthPrompts = async () => { + await getUsageTracker().resetStats(); + console.log("[SayPi] Auth prompt tracking reset. Reload page to take effect."); + }; + // Initialize telemetry module logger.debug("Initializing telemetry module"); telemetryModule; // This will invoke the getInstance() singleton which sets up event listeners diff --git a/src/styles/auth-prompt.scss b/src/styles/auth-prompt.scss new file mode 100644 index 0000000000..55c4bd14fc --- /dev/null +++ b/src/styles/auth-prompt.scss @@ -0,0 +1,535 @@ +// Auth Prompt Styling +// Progressive authentication prompts - toast and modal variants + +// Common variables +$primary-color: rgba(59, 130, 246, 1); // Blue +$primary-hover: rgba(37, 99, 235, 1); +$primary-light: rgba(59, 130, 246, 0.1); +$secondary-color: rgba(107, 114, 128, 0.8); +$success-color: rgba(16, 185, 129, 1); +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; + +// ============================================================ +// TOAST NOTIFICATION +// Non-blocking notification in bottom-right corner +// Positioned above VAD status indicator (which is at bottom: 20px) +// ============================================================ +.saypi-auth-toast { + position: fixed; + bottom: 100px; // Above VAD status indicator + right: 1.5rem; + z-index: 10001; + max-width: 380px; + padding: 0; + border-radius: 0.75rem; + background: white; + border: 1px solid rgba(229, 231, 235, 0.8); + font-family: $font-family; + font-size: 0.875rem; + line-height: 1.4; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15), 0 4px 10px rgba(0, 0, 0, 0.08); + opacity: 0; + transform: translateY(20px) scale(0.95); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &.visible { + opacity: 1; + transform: translateY(0) scale(1); + } + + .saypi-auth-prompt-content { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + padding-right: 2.5rem; // Extra space for absolute-positioned close button + position: relative; + } + + .saypi-auth-prompt-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%); + border-radius: 50%; + color: white; + box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3); + // No filter needed - bubble-bw.svg already has white bubble with black "Pi" + } + + .saypi-auth-prompt-text { + flex: 1; + color: rgba(55, 65, 81, 1); + font-size: 0.875rem; + } + + .saypi-auth-prompt-signin { + flex-shrink: 0; + padding: 0.5rem 1rem; + background: $primary-color; + color: white; + border: none; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + + &:hover { + background: $primary-hover; + transform: translateY(-1px); + } + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + } + + &:active { + transform: translateY(0); + } + } + + .saypi-auth-prompt-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + width: 1.5rem; + height: 1.5rem; + border: none; + background: rgba(107, 114, 128, 0.1); + border-radius: 50%; + color: rgba(107, 114, 128, 0.6); + font-size: 1rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + background: rgba(107, 114, 128, 0.2); + color: rgba(107, 114, 128, 0.8); + } + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + } + } + + // Dark mode + @media (prefers-color-scheme: dark) { + background: rgba(31, 41, 55, 1); + border-color: rgba(55, 65, 81, 0.8); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4), 0 4px 10px rgba(0, 0, 0, 0.25); + + .saypi-auth-prompt-icon { + background: linear-gradient(135deg, #66BB6A 0%, #43A047 100%); + box-shadow: 0 2px 8px rgba(102, 187, 106, 0.3); + } + + .saypi-auth-prompt-text { + color: rgba(229, 231, 235, 1); + } + + .saypi-auth-prompt-close { + background: rgba(156, 163, 175, 0.1); + color: rgba(156, 163, 175, 0.7); + + &:hover { + background: rgba(156, 163, 175, 0.2); + color: rgba(156, 163, 175, 0.9); + } + } + } + + // Mobile + @media (max-width: 768px) { + bottom: 1rem; + right: 1rem; + left: 1rem; + max-width: none; + + .saypi-auth-prompt-content { + flex-wrap: wrap; + gap: 0.75rem; + } + + .saypi-auth-prompt-text { + flex-basis: calc(100% - 60px); + } + + .saypi-auth-prompt-signin { + width: 100%; + } + } +} + +// ============================================================ +// OVERLAY (for modals) +// ============================================================ +.saypi-auth-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + opacity: 0; + transition: opacity 0.3s ease; + + &.visible { + opacity: 1; + } + + &.saypi-auth-overlay-soft { + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(2px); + } +} + +// ============================================================ +// MODAL +// Centered modal with value proposition +// ============================================================ +.saypi-auth-modal { + position: fixed; + top: 50%; + left: 50%; + z-index: 10001; + width: 90%; + max-width: 420px; + padding: 0; + border-radius: 1rem; + background: white; + font-family: $font-family; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25), 0 10px 20px rgba(0, 0, 0, 0.15); + opacity: 0; + transform: translate(-50%, -50%) scale(0.9); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &.visible { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + &.saypi-auth-modal-soft { + max-width: 380px; + } + + .saypi-auth-modal-content { + padding: 2rem; + text-align: center; + position: relative; + } + + .saypi-auth-modal-close { + position: absolute; + top: 1rem; + right: 1rem; + width: 2rem; + height: 2rem; + border: none; + background: rgba(107, 114, 128, 0.1); + border-radius: 50%; + color: rgba(107, 114, 128, 0.6); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + background: rgba(107, 114, 128, 0.2); + color: rgba(107, 114, 128, 0.8); + transform: scale(1.1); + } + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + } + + &:active { + transform: scale(0.95); + } + } + + .saypi-auth-modal-brand { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #4CAF50; + margin-bottom: 1rem; + } + + .saypi-auth-modal-icon { + display: flex; + align-items: center; + justify-content: center; + width: 88px; + height: 88px; + margin: 0 auto 1.25rem; + background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%); + border-radius: 50%; + color: white; + box-shadow: 0 4px 16px rgba(76, 175, 80, 0.35), 0 2px 8px rgba(0, 0, 0, 0.1); + // No filter needed - bubble-bw.svg already has white bubble with black "Pi" + + svg { + width: 56px; + height: 56px; + } + } + + .saypi-auth-modal-title { + margin: 0 0 0.75rem; + font-size: 1.5rem; + font-weight: 600; + color: rgba(17, 24, 39, 1); + } + + .saypi-auth-modal-description { + margin: 0 0 1.5rem; + font-size: 0.9375rem; + line-height: 1.6; + color: rgba(107, 114, 128, 1); + } + + .saypi-auth-modal-benefits { + list-style: none; + margin: 0 0 1.5rem; + padding: 0; + text-align: left; + + li { + position: relative; + padding: 0.5rem 0 0.5rem 1.75rem; + font-size: 0.875rem; + color: rgba(55, 65, 81, 1); + + &::before { + content: "✓"; + position: absolute; + left: 0; + top: 0.5rem; + width: 1.25rem; + height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + background: rgba(16, 185, 129, 0.1); + border-radius: 50%; + color: $success-color; + font-size: 0.75rem; + font-weight: 600; + } + } + } + + .saypi-auth-modal-buttons { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .saypi-auth-modal-signin { + width: 100%; + padding: 0.875rem 1.5rem; + background: $primary-color; + color: white; + border: none; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: $primary-hover; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35); + } + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + } + + &:active { + transform: translateY(0); + } + } + + .saypi-auth-modal-later { + width: 100%; + padding: 0.75rem 1.5rem; + background: transparent; + color: $secondary-color; + border: none; + border-radius: 0.5rem; + font-size: 0.9375rem; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(107, 114, 128, 0.1); + color: rgba(55, 65, 81, 1); + } + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + } + } + + // Dark mode + @media (prefers-color-scheme: dark) { + background: rgba(31, 41, 55, 1); + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5), 0 10px 20px rgba(0, 0, 0, 0.35); + + .saypi-auth-modal-close { + background: rgba(156, 163, 175, 0.1); + color: rgba(156, 163, 175, 0.7); + + &:hover { + background: rgba(156, 163, 175, 0.2); + color: rgba(156, 163, 175, 0.9); + } + } + + .saypi-auth-modal-brand { + color: #66BB6A; + } + + .saypi-auth-modal-icon { + background: linear-gradient(135deg, #66BB6A 0%, #43A047 100%); + box-shadow: 0 4px 16px rgba(102, 187, 106, 0.3), 0 2px 8px rgba(0, 0, 0, 0.2); + } + + .saypi-auth-modal-title { + color: rgba(243, 244, 246, 1); + } + + .saypi-auth-modal-description { + color: rgba(156, 163, 175, 1); + } + + .saypi-auth-modal-benefits li { + color: rgba(229, 231, 235, 1); + + &::before { + background: rgba(76, 175, 80, 0.15); + color: rgba(129, 199, 132, 1); + } + } + + .saypi-auth-modal-signin { + &:hover { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25); + } + } + + .saypi-auth-modal-later { + color: rgba(156, 163, 175, 1); + + &:hover { + background: rgba(156, 163, 175, 0.1); + color: rgba(229, 231, 235, 1); + } + } + } + + // Mobile + @media (max-width: 768px) { + width: calc(100% - 2rem); + max-width: none; + + .saypi-auth-modal-content { + padding: 1.5rem; + } + + .saypi-auth-modal-icon { + width: 64px; + height: 64px; + margin-bottom: 1rem; + + svg { + width: 36px; + height: 36px; + } + } + + .saypi-auth-modal-title { + font-size: 1.25rem; + } + + .saypi-auth-modal-description { + font-size: 0.875rem; + } + } +} + +// ============================================================ +// ACCESSIBILITY +// ============================================================ + +// Reduced motion +@media (prefers-reduced-motion: reduce) { + .saypi-auth-toast, + .saypi-auth-modal, + .saypi-auth-overlay { + transition: opacity 0.2s ease; + transform: none; + + &.visible { + transform: none; + } + } + + .saypi-auth-modal.visible { + transform: translate(-50%, -50%); + } + + .saypi-auth-prompt-signin, + .saypi-auth-modal-signin, + .saypi-auth-prompt-close, + .saypi-auth-modal-close { + transition: background-color 0.2s ease, color 0.2s ease; + + &:hover, + &:active { + transform: none; + } + } +} + +// High contrast +@media (prefers-contrast: high) { + .saypi-auth-toast, + .saypi-auth-modal { + border: 2px solid currentColor; + } + + .saypi-auth-prompt-signin, + .saypi-auth-modal-signin { + border: 2px solid currentColor; + } + + .saypi-auth-prompt-close, + .saypi-auth-modal-close { + border: 1px solid currentColor; + background: transparent; + } +} diff --git a/src/svc/background.ts b/src/svc/background.ts index 0cb92de51c..40b36fb3a3 100644 --- a/src/svc/background.ts +++ b/src/svc/background.ts @@ -26,6 +26,20 @@ const POPUP_DESKTOP_WIDTH = POPUP_MIN_CONTENT_WIDTH + 6; // The buffer value 6 a async function openSettingsWindow() { try { const popupURL = getExtensionURL('settings.html'); + + // Check if settings window/tab already exists + const existingTab = await findExistingSettingsTab(popupURL); + if (existingTab) { + // Focus existing window/tab instead of opening a new one + if (existingTab.windowId !== undefined) { + await browser.windows.update(existingTab.windowId, { focused: true }); + } + if (existingTab.id !== undefined) { + await browser.tabs.update(existingTab.id, { active: true }); + } + return; + } + // Decide initial height based on whether we need to show consent overlay // We check local storage flag 'shareData'. If it's undefined, consent will show. const { shareData } = await browser.storage.local.get('shareData'); @@ -49,6 +63,32 @@ async function openSettingsWindow() { } } +/** + * Find an existing settings tab if one is already open + */ +async function findExistingSettingsTab(settingsUrl: string): Promise { + try { + // Query all tabs for our settings URL + const tabs = await browser.tabs.query({ url: settingsUrl }); + if (tabs.length > 0) { + return tabs[0]; + } + + // Also check with wildcard in case URL has query params or hash + const baseUrl = settingsUrl.replace(/\?.*$/, '').replace(/#.*$/, ''); + const tabsWithWildcard = await browser.tabs.query({ url: baseUrl + '*' }); + if (tabsWithWildcard.length > 0) { + return tabsWithWildcard[0]; + } + + return null; + } catch (error) { + // tabs.query may fail in some contexts, fall back to creating new window + console.debug('Could not query tabs:', error); + return null; + } +} + // Open settings when the toolbar icon is clicked (no default_popup) const actionNamespaces: Array< | { diff --git a/test/auth/AuthPromptController.spec.ts b/test/auth/AuthPromptController.spec.ts new file mode 100644 index 0000000000..4d30322e9e --- /dev/null +++ b/test/auth/AuthPromptController.spec.ts @@ -0,0 +1,181 @@ +/** + * Tests for AuthPromptController logic + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import EventBus from "../../src/events/EventBus"; + +// Test the core logic of AuthPromptController without singleton complexity +describe("AuthPromptController logic", () => { + beforeEach(() => { + vi.clearAllMocks(); + EventBus.removeAllListeners(); + }); + + afterEach(() => { + EventBus.removeAllListeners(); + }); + + describe("threshold configuration", () => { + it("should merge custom thresholds with defaults", () => { + const DEFAULT_THRESHOLDS = { + toast: 5, + softModal: 10, + modal: 20, + }; + + const customThresholds = { toast: 3 }; + const merged = { ...DEFAULT_THRESHOLDS, ...customThresholds }; + + expect(merged.toast).toBe(3); + expect(merged.softModal).toBe(10); + expect(merged.modal).toBe(20); + }); + }); + + describe("prompt level determination", () => { + const thresholds = { toast: 5, softModal: 10, modal: 20 }; + + const determinePromptLevel = ( + transcriptionCount: number, + lastLevel: string | undefined + ): string | null => { + if (transcriptionCount >= thresholds.modal) { + if (lastLevel !== "modal") return "modal"; + } else if (transcriptionCount >= thresholds.softModal) { + if (lastLevel !== "soft-modal" && lastLevel !== "modal") return "soft-modal"; + } else if (transcriptionCount >= thresholds.toast) { + if (!lastLevel) return "toast"; + } + return null; + }; + + it("should return toast at 5 transcriptions for new user", () => { + expect(determinePromptLevel(5, undefined)).toBe("toast"); + }); + + it("should return soft-modal at 10 transcriptions after toast", () => { + expect(determinePromptLevel(10, "toast")).toBe("soft-modal"); + }); + + it("should return modal at 20 transcriptions", () => { + expect(determinePromptLevel(20, "soft-modal")).toBe("modal"); + }); + + it("should not repeat same level", () => { + expect(determinePromptLevel(25, "modal")).toBeNull(); + }); + + it("should skip to highest level if never shown", () => { + expect(determinePromptLevel(25, undefined)).toBe("modal"); + }); + + it("should not show prompt below threshold", () => { + expect(determinePromptLevel(3, undefined)).toBeNull(); + }); + }); + + describe("authentication check", () => { + it("should not show prompts when authenticated", () => { + const isAuthenticated = true; + const shouldShowPrompt = !isAuthenticated; + + expect(shouldShowPrompt).toBe(false); + }); + + it("should allow prompts when not authenticated", () => { + const isAuthenticated = false; + const shouldShowPrompt = !isAuthenticated; + + expect(shouldShowPrompt).toBe(true); + }); + }); + + describe("dismissal handling", () => { + it("should increment dismissal count", () => { + let promptsDismissed = 0; + + const recordDismissal = () => { + promptsDismissed++; + }; + + recordDismissal(); + expect(promptsDismissed).toBe(1); + + recordDismissal(); + expect(promptsDismissed).toBe(2); + }); + + it("should stop prompts after max dismissals", () => { + const MAX_DISMISSALS = 5; + + const shouldStopPrompts = (dismissedCount: number) => { + return dismissedCount >= MAX_DISMISSALS; + }; + + expect(shouldStopPrompts(4)).toBe(false); + expect(shouldStopPrompts(5)).toBe(true); + }); + }); + + describe("cooldown calculation", () => { + it("should use exponential backoff based on dismissals", () => { + const BASE_COOLDOWN = 5 * 60 * 1000; // 5 min + const MAX_COOLDOWN = 24 * 60 * 60 * 1000; // 24 hours + + const getCooldown = (dismissedCount: number) => { + return Math.min(BASE_COOLDOWN * Math.pow(2, dismissedCount), MAX_COOLDOWN); + }; + + // 0 dismissals: 5 min + expect(getCooldown(0)).toBe(5 * 60 * 1000); + + // 1 dismissal: 10 min + expect(getCooldown(1)).toBe(10 * 60 * 1000); + + // 4 dismissals: 80 min + expect(getCooldown(4)).toBe(80 * 60 * 1000); + }); + }); + + describe("event emission", () => { + it("should emit show event with correct data", () => { + const handler = vi.fn(); + EventBus.on("saypi:authPrompt:show", handler); + + const showData = { + level: "toast" as const, + transcriptionCount: 5, + dismissedCount: 0, + }; + + EventBus.emit("saypi:authPrompt:show", showData); + + expect(handler).toHaveBeenCalledWith(showData); + }); + + it("should emit hide event", () => { + const handler = vi.fn(); + EventBus.on("saypi:authPrompt:hide", handler); + + EventBus.emit("saypi:authPrompt:hide"); + + expect(handler).toHaveBeenCalled(); + }); + }); + + describe("usage update handling", () => { + it("should process usage update events", () => { + const handler = vi.fn(); + EventBus.on("saypi:usage:updated", handler); + + const usageData = { + transcriptionCount: 5, + firstUseDate: Date.now(), + }; + + EventBus.emit("saypi:usage:updated", usageData); + + expect(handler).toHaveBeenCalledWith(usageData); + }); + }); +}); diff --git a/test/auth/UsageTracker.spec.ts b/test/auth/UsageTracker.spec.ts new file mode 100644 index 0000000000..75309495d7 --- /dev/null +++ b/test/auth/UsageTracker.spec.ts @@ -0,0 +1,146 @@ +/** + * Tests for UsageTracker + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import EventBus from "../../src/events/EventBus"; + +// We'll test the core logic without relying on the singleton pattern +describe("UsageTracker logic", () => { + beforeEach(() => { + vi.clearAllMocks(); + EventBus.removeAllListeners(); + }); + + afterEach(() => { + EventBus.removeAllListeners(); + }); + + describe("UsageStats interface", () => { + it("should have the expected structure", async () => { + // Import the interface + const { UsageTracker } = await import("../../src/auth/UsageTracker"); + + // The default stats should have all required fields + const defaultStats = { + interactionCount: 0, + transcriptionCount: 0, // deprecated but kept for compatibility + firstUseDate: 0, + lastPromptShown: 0, + promptsDismissed: 0, + lastPromptLevel: undefined, + }; + + expect(defaultStats).toHaveProperty("interactionCount"); + expect(defaultStats).toHaveProperty("transcriptionCount"); + expect(defaultStats).toHaveProperty("firstUseDate"); + expect(defaultStats).toHaveProperty("lastPromptShown"); + expect(defaultStats).toHaveProperty("promptsDismissed"); + }); + }); + + describe("cooldown calculation", () => { + it("should correctly calculate if cooldown has passed", () => { + const now = Date.now(); + const oneHourAgo = now - 3600000; + const fiveMinutesAgo = now - 300000; + + // Helper function to check cooldown (same logic as UsageTracker) + const hasCooldownPassed = (lastPromptShown: number, cooldownMs: number) => { + if (lastPromptShown === 0) return true; + return Date.now() - lastPromptShown > cooldownMs; + }; + + // No prompt shown yet + expect(hasCooldownPassed(0, 300000)).toBe(true); + + // Prompt shown an hour ago, 5 min cooldown + expect(hasCooldownPassed(oneHourAgo, 300000)).toBe(true); + + // Prompt shown 5 min ago, 1 hour cooldown + expect(hasCooldownPassed(fiveMinutesAgo, 3600000)).toBe(false); + }); + }); + + describe("prompt level determination", () => { + it("should determine correct prompt level based on interaction count", () => { + const thresholds = { + toast: 5, + softModal: 10, + modal: 20, + }; + + // Helper function (same logic as AuthPromptController) + const determinePromptLevel = ( + count: number, + lastLevel: string | undefined + ): string | null => { + if (count >= thresholds.modal && lastLevel !== "modal") { + return "modal"; + } + if (count >= thresholds.softModal && lastLevel !== "soft-modal" && lastLevel !== "modal") { + return "soft-modal"; + } + if (count >= thresholds.toast && !lastLevel) { + return "toast"; + } + return null; + }; + + // Fresh user at 5 interactions -> toast + expect(determinePromptLevel(5, undefined)).toBe("toast"); + + // User at 10 after seeing toast -> soft-modal + expect(determinePromptLevel(10, "toast")).toBe("soft-modal"); + + // User at 20 after seeing soft-modal -> modal + expect(determinePromptLevel(20, "soft-modal")).toBe("modal"); + + // User at 25 after seeing modal -> no new prompt + expect(determinePromptLevel(25, "modal")).toBeNull(); + + // Fresh user at 25 -> modal directly + expect(determinePromptLevel(25, undefined)).toBe("modal"); + }); + }); + + describe("dismissal backoff", () => { + it("should calculate exponential backoff for cooldown", () => { + const BASE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes + const MAX_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours + + const calculateCooldown = (dismissedCount: number) => { + return Math.min( + BASE_COOLDOWN_MS * Math.pow(2, dismissedCount), + MAX_COOLDOWN_MS + ); + }; + + // No dismissals -> base cooldown (5 min) + expect(calculateCooldown(0)).toBe(300000); + + // 1 dismissal -> 10 min + expect(calculateCooldown(1)).toBe(600000); + + // 2 dismissals -> 20 min + expect(calculateCooldown(2)).toBe(1200000); + + // 10 dismissals -> should cap at max (24 hours) + expect(calculateCooldown(10)).toBe(MAX_COOLDOWN_MS); + }); + }); + + describe("max dismissals", () => { + it("should disable prompts after max dismissals", () => { + const MAX_DISMISSALS = 5; + + const arePromptsEnabled = (dismissedCount: number) => { + return dismissedCount < MAX_DISMISSALS; + }; + + expect(arePromptsEnabled(0)).toBe(true); + expect(arePromptsEnabled(4)).toBe(true); + expect(arePromptsEnabled(5)).toBe(false); + expect(arePromptsEnabled(10)).toBe(false); + }); + }); +});