// Constants for favicon management
const FAVICON_STATES = {
INITIAL: 'initial',
CUSTOM: 'custom',
EMOJI: 'emoji',
COLOR: 'color',
RESTORING: 'restoring'
};
const FAVICON_PRIORITIES = {
RESTORE: 3,
USER_ACTION: 2,
ANIMATION: 1,
DEFAULT: 0
};
// Constants for state management
const STATE_KEYS = {
SEQUENCE: 'tabtitle_sequence_state',
FAVICON: 'tabtitle_favicon_state',
USER_PREFS: 'tabtitle_user_prefs',
VERSION: 'tabtitle_state_version'
};
const CURRENT_STATE_VERSION = '1.0';
// State Manager for persistence
class StateManager {
constructor() {
this.version = CURRENT_STATE_VERSION;
this.initialized = false;
this.storageAvailable = this._checkStorageAvailability();
}
_checkStorageAvailability() {
try {
const storage = window.localStorage;
const testKey = '__storage_test__';
storage.setItem(testKey, testKey);
storage.removeItem(testKey);
return true;
} catch (e) {
return false;
}
}
initialize() {
if (!this.storageAvailable) {
console.warn('LocalStorage is not available. State persistence will be disabled.');
return;
}
// Check version and handle migrations if needed
const savedVersion = localStorage.getItem(STATE_KEYS.VERSION);
if (savedVersion !== this.version) {
this._handleVersionMigration(savedVersion);
}
this.initialized = true;
}
_handleVersionMigration(oldVersion) {
// Handle state migrations between versions
// For now, just clear old state and set new version
this.clearAll();
localStorage.setItem(STATE_KEYS.VERSION, this.version);
}
saveSequenceState(state) {
if (!this.initialized || !this.storageAvailable) return;
try {
const serializedState = JSON.stringify({
currentIndex: state.currentIndex,
isRunning: state.isRunning,
startTime: state.startTime,
currentItemStartTime: state.currentItemStartTime,
delayElapsed: state.delayElapsed,
remainingDelay: state.remainingDelay,
sequenceMetadata: state.sequenceMetadata,
timestamp: Date.now()
});
localStorage.setItem(STATE_KEYS.SEQUENCE, serializedState);
} catch (error) {
console.warn('Failed to save sequence state:', error);
}
}
loadSequenceState() {
if (!this.initialized || !this.storageAvailable) return null;
try {
const savedState = localStorage.getItem(STATE_KEYS.SEQUENCE);
if (!savedState) return null;
const state = JSON.parse(savedState);
// Only return state if it's less than 30 minutes old
if (Date.now() - state.timestamp < 30 * 60 * 1000) {
return {
...state,
sequenceMetadata: state.sequenceMetadata || {
groupId: null,
type: null,
totalItems: 0,
completedItems: 0,
lastCompletedTimestamp: null
}
};
}
// Clear old state
localStorage.removeItem(STATE_KEYS.SEQUENCE);
return null;
} catch (error) {
console.warn('Failed to load sequence state:', error);
return null;
}
}
saveFaviconState(state) {
if (!this.initialized || !this.storageAvailable) return;
try {
const serializedState = JSON.stringify({
currentFavicon: state.currentFavicon,
isUpdating: state.isUpdating,
original: state.original,
customActive: state.customActive, // Add customActive flag to saved state
timestamp: Date.now()
});
localStorage.setItem(STATE_KEYS.FAVICON, serializedState);
} catch (error) {
console.warn('Failed to save favicon state:', error);
}
}
loadFaviconState() {
if (!this.initialized || !this.storageAvailable) return null;
try {
const savedState = localStorage.getItem(STATE_KEYS.FAVICON);
if (!savedState) return null;
const state = JSON.parse(savedState);
// Only return state if it's less than 30 minutes old
if (Date.now() - state.timestamp < 30 * 60 * 1000) {
return state;
}
// Clear old state
localStorage.removeItem(STATE_KEYS.FAVICON);
return null;
} catch (error) {
console.warn('Failed to load favicon state:', error);
return null;
}
}
saveUserPreferences(prefs) {
if (!this.initialized || !this.storageAvailable) return;
try {
localStorage.setItem(STATE_KEYS.USER_PREFS, JSON.stringify(prefs));
} catch (error) {
console.warn('Failed to save user preferences:', error);
}
}
loadUserPreferences() {
if (!this.initialized || !this.storageAvailable) return null;
try {
const savedPrefs = localStorage.getItem(STATE_KEYS.USER_PREFS);
return savedPrefs ? JSON.parse(savedPrefs) : null;
} catch (error) {
console.warn('Failed to load user preferences:', error);
return null;
}
}
clearAll() {
if (!this.storageAvailable) return;
Object.values(STATE_KEYS).forEach(key => {
localStorage.removeItem(key);
});
}
}
// Sequence types and manager
class SequenceManager {
constructor() {
this.currentSequence = [];
this.currentIndex = 0;
this.state = {
isRunning: false,
startTime: 0,
currentItemStartTime: 0,
delayElapsed: 0,
remainingDelay: 0,
sequenceMetadata: {
groupId: null,
type: null,
totalItems: 0,
completedItems: 0,
lastCompletedTimestamp: null,
currentCycle: 0
}
};
this.timeouts = {
delay: null,
duration: null
};
this.defaultDelay = 0;
this.retryAttempts = 0;
this.maxRetries = 3;
this.debug = true;
this.lastError = null;
}
_log(message, data = null) {
if (this.debug) {
console.log(`[SequenceManager] ${message}`, data || '');
}
}
start(sequence, metadata = {}) {
this._log('SequenceManager.start called with sequence:', sequence);
this._log('SequenceManager.start called with metadata:', metadata);
this.stop();
this.currentSequence = sequence;
this.currentIndex = 0;
this.lastError = null;
this.state = {
...this.state,
isRunning: true,
startTime: Date.now(),
currentItemStartTime: Date.now(),
delayElapsed: 0,
remainingDelay: 0,
sequenceMetadata: {
groupId: metadata.groupId || null,
type: this._determineSequenceType(sequence),
totalItems: sequence.length,
completedItems: 0,
lastCompletedTimestamp: null,
currentCycle: 0
}
};
this.retryAttempts = 0;
if (this.currentSequence.length > 0) {
this.startCurrentItem();
}
}
_determineSequenceType(sequence) {
if (!sequence || sequence.length === 0) return null;
const types = new Set(sequence.map(item => item.type));
if (types.size === 1) {
return Array.from(types)[0]; // 'message' or 'favicon'
}
return 'mixed';
}
stop() {
this.state.isRunning = false;
this.clearTimeouts();
this.retryAttempts = 0;
}
pause() {
if (this.state.isRunning) {
this.state.isRunning = false;
this.state.delayElapsed = Date.now() - this.state.currentItemStartTime;
this.state.remainingDelay = this._calculateRemainingDelay();
this.clearTimeouts();
}
}
resume() {
if (!this.state.isRunning && this.currentSequence.length > 0) {
this.state.isRunning = true;
this.state.currentItemStartTime = Date.now() - this.state.delayElapsed;
// Calculate remaining delay based on when we paused
const remainingDelay = this._calculateRemainingDelay();
this.startCurrentItem(remainingDelay);
}
}
_calculateRemainingDelay() {
const currentItem = this.getCurrentItem();
if (!currentItem) return 0;
const itemDelay = currentItem.delay || this.defaultDelay;
const elapsedTime = Date.now() - this.state.currentItemStartTime;
return Math.max(0, itemDelay - elapsedTime);
}
clearTimeouts() {
Object.values(this.timeouts).forEach(timeout => {
if (timeout) {
clearTimeout(timeout);
}
});
this.timeouts = {
delay: null,
duration: null
};
}
startCurrentItem(remainingDelay = 0) {
const item = this.getCurrentItem();
if (!item) return;
const delay = remainingDelay > 0 ? remainingDelay : (item.delay || this.defaultDelay);
if (delay > 0) {
this.timeouts.delay = setTimeout(() => {
this.executeItem(item);
}, delay);
} else {
this.executeItem(item);
}
}
executeItem(item) {
if (document.hidden) {
this._log('SequenceManager.executeItem called with item:', item);
if (!this.state.isRunning) {
this._log('SequenceManager.executeItem: WARNING - state is NOT running, but executing.');
} else {
this._log('SequenceManager.executeItem: state is running, proceeding.');
}
this._log('Executing sequence item', {
item,
currentIndex: this.currentIndex,
timestamp: new Date(),
cycle: this.state.sequenceMetadata.currentCycle
});
} else {
// Optional: console.log(`[SequenceManager] Executing item ${this.currentIndex} (tab visible)`);
}
if (!this.state.isRunning && !document.hidden) {
this._log('SequenceManager.executeItem: State became not running while item was pending (likely tab became visible). Halting execution for this item.');
return;
}
try {
this.clearTimeouts();
if (item.duration > 0) {
this.timeouts.duration = setTimeout(() => {
if (document.hidden) this._log('Item duration completed', {
item,
duration: item.duration
});
this.moveToNextItem();
}, item.duration);
}
this.state.sequenceMetadata.completedItems++;
if (this.onItemStart) {
if (document.hidden) this._log('SequenceManager.executeItem is calling onItemStart for item:', item);
this.onItemStart(item);
}
this.retryAttempts = 0;
this.lastError = null;
} catch (error) {
this.lastError = error;
this._log('Error executing item', error); // Error logs should remain unconditional
this._handleExecutionError(item);
}
}
_handleExecutionError(item) {
if (this.retryAttempts < this.maxRetries) {
this.retryAttempts++;
this._log(`Retrying sequence item execution`, {
attempt: this.retryAttempts,
maxRetries: this.maxRetries,
item,
error: this.lastError
});
// Store current state before retry
const currentState = { ...this.state };
setTimeout(() => {
// Restore state before retrying
this.state = currentState;
this.executeItem(item);
}, 1000);
} else {
this._log('Max retry attempts reached', {
item,
error: this.lastError
});
this.moveToNextItem();
}
}
moveToNextItem() {
if (!this.state.isRunning) return;
if (document.hidden) {
this._log('Moving to next item', {
currentIndex: this.currentIndex,
nextIndex: (this.currentIndex + 1) % this.currentSequence.length
});
}
this.currentIndex = (this.currentIndex + 1) % this.currentSequence.length;
this.state.currentItemStartTime = Date.now();
this.state.delayElapsed = 0;
this.clearTimeouts();
if (this.currentIndex === 0) {
// Completed one full cycle
this.state.sequenceMetadata.currentCycle++;
this.state.sequenceMetadata.lastCompletedTimestamp = Date.now();
if (this.onSequenceComplete) {
this.onSequenceComplete(this.state.sequenceMetadata);
}
}
this.startCurrentItem();
}
getCurrentItem() {
return this.currentSequence[this.currentIndex];
}
setItemStartHandler(handler) {
this.onItemStart = handler;
}
setSequenceCompleteHandler(handler) {
this.onSequenceComplete = handler;
}
getState() {
return {
...this.state,
currentIndex: this.currentIndex,
currentSequence: this.currentSequence,
sequenceMetadata: this.state.sequenceMetadata
};
}
setState(state) {
if (!state) return;
this._log('Setting state', state);
this.state = {
isRunning: state.isRunning || false,
startTime: state.startTime || 0,
currentItemStartTime: state.currentItemStartTime || 0,
delayElapsed: state.delayElapsed || 0,
remainingDelay: state.remainingDelay || 0,
sequenceMetadata: state.sequenceMetadata || {
groupId: null,
type: null,
totalItems: 0,
completedItems: 0,
lastCompletedTimestamp: null
}
};
this.currentIndex = state.currentIndex || 0;
if (state.currentSequence) {
this.currentSequence = state.currentSequence;
}
}
}
class FaviconManager {
constructor() {
this.state = {
isUpdating: false, // Internal flag to prevent observer feedback loops
currentHref: null, // What the script has set the href to
originalHref: null, // The href of the icon found on page load, or null
customActive: false // Is the script actively managing the favicon?
};
this.managedElement = null; // The element being managed
this.hadOriginalLinkTag = false; // Was a tag present initially?
this.observer = null;
this.initialized = false;
this.debug = true;
}
_log(message, data = null) {
if (this.debug) {
console.log(`[FaviconManager] ${message}`, data ? JSON.stringify(data, null, 2) : '');
}
}
initialize() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this._log('DOMContentLoaded event fired');
setTimeout(() => this._init(), 100);
});
} else {
this._log('Document already loaded');
setTimeout(() => this._init(), 100);
}
window.addEventListener('load', () => {
this._log('Window load event fired');
setTimeout(() => { // Delay to catch favicons added by other scripts on load
const finalExistingElement = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]');
if (finalExistingElement && finalExistingElement.href) {
if (!this.state.customActive) { // Only update original if script hasn't taken over
this._log('Favicon link found/confirmed after window.load.', { href: finalExistingElement.href });
this.state.originalHref = finalExistingElement.href;
// If script hasn't set a custom one yet, reflect this loaded one.
this.state.currentHref = finalExistingElement.href;
this.managedElement = finalExistingElement;
this.hadOriginalLinkTag = true;
}
} else {
// No favicon element found even after window.load
if (!this.hadOriginalLinkTag && !this.state.customActive) { // and we didn't find one earlier and script is not active
this._log('No favicon tag found after window.load, originalHref remains null.');
// this.state.originalHref is already null or was set to null in _init
}
}
this._log('Favicon state after load event processed', { originalHref: this.state.originalHref, hadOriginalLinkTag: this.hadOriginalLinkTag, currentHref: this.state.currentHref });
}, 500); // Increased delay
});
}
_init() {
this._log('Initializing FaviconManager');
const existingElement = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]');
if (existingElement && existingElement.href) {
this._log('Found existing favicon element on init', { href: existingElement.href });
this.hadOriginalLinkTag = true;
this.state.originalHref = existingElement.href;
this.state.currentHref = existingElement.href; // Initially, current is original
this.managedElement = existingElement; // Manage the existing element
} else {
this._log('No existing favicon tag found on init.');
this.hadOriginalLinkTag = false;
this.state.originalHref = null;
this.state.currentHref = null;
this.managedElement = null; // No element to manage yet
}
this._log('Initial favicon state stored', { originalHref: this.state.originalHref, hadOriginalLinkTag: this.hadOriginalLinkTag });
this.observer = new MutationObserver((mutations) => {
if (this.state.isUpdating || this.state.customActive || document.hidden) return;
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'href' && mutation.target.hasAttribute('rel') && (mutation.target.getAttribute('rel') === 'icon' || mutation.target.getAttribute('rel') === 'shortcut icon')) {
const newHref = mutation.target.href;
// Check if the href is not empty and actually changed from what we know as original
if (newHref && newHref !== this.state.originalHref) {
this._log('External update to a favicon link detected while script inactive and tab visible. Updating originalHref.', { newHref });
this.state.originalHref = newHref;
this.state.currentHref = newHref; // Reflect this as current
this.managedElement = mutation.target; // Consider this the element to manage now
this.hadOriginalLinkTag = true; // If an icon appeared/changed, we now consider it as having an original tag.
}
}
// If a link tag is ADDED and we didn't have one previously recorded:
if (mutation.type === 'childList' && !this.hadOriginalLinkTag) {
mutation.addedNodes.forEach(node => {
if (node.nodeName === 'LINK' && (node.getAttribute('rel') === 'icon' || node.getAttribute('rel') === 'shortcut icon') && node.href) {
this._log('Favicon link added externally, adopting as original.', { href: node.href });
this.state.originalHref = node.href;
this.state.currentHref = node.href;
this.managedElement = node;
this.hadOriginalLinkTag = true;
}
});
}
});
});
this.observer.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
this.initialized = true;
this._log('FaviconManager initialized');
}
setFavicon(url) {
if (!this.initialized || !url) {
this._log('Cannot set favicon - not initialized or no URL provided', {
initialized: this.initialized,
url
});
return;
}
this._log('Setting favicon', { url, previousHref: this.state.currentHref });
this.state.isUpdating = true;
if (!this.managedElement) {
// If no original tag was found and we haven't created one yet.
this._log('No managed favicon element, creating a new one.');
this.managedElement = document.createElement('link');
this.managedElement.rel = 'icon';
document.head.appendChild(this.managedElement);
// this.hadOriginalLinkTag remains false if it was initially false.
// this.state.originalHref remains null if it was initially null.
}
this.managedElement.href = url;
this.state.currentHref = url;
this.state.customActive = true; // Script is now actively controlling the favicon.
this._log('Favicon href updated on managed element.');
setTimeout(() => {
this.state.isUpdating = false;
this._log('Reset updating flag');
}, 100);
}
restore() {
if (!this.initialized) {
this._log('Cannot restore favicon - not initialized.');
return;
}
this._log('Attempting to restore favicon.', { originalHref: this.state.originalHref, hadOriginalLinkTag: this.hadOriginalLinkTag });
this.state.customActive = false; // Signal script is no longer managing favicon early
this.state.isUpdating = true;
if (this.hadOriginalLinkTag && this.state.originalHref) {
// An original favicon tag was present and we have its href.
if (!this.managedElement) {
// This implies the original (or our managed one) was removed from DOM by external script.
// Try to find it again or re-create a link element for the original.
let existingOriginal = null;
const favicons = document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]');
for (let i = 0; i < favicons.length; i++) {
if (favicons[i].href === this.state.originalHref) {
existingOriginal = favicons[i];
break;
}
}
if (existingOriginal) {
this.managedElement = existingOriginal;
this._log('Found existing original element again.');
} else {
this._log('Original link tag was present, but managedElement is null and original not found. Re-creating link for original favicon.');
this.managedElement = document.createElement('link');
this.managedElement.rel = 'icon';
document.head.appendChild(this.managedElement);
}
}
this.managedElement.href = this.state.originalHref;
this.state.currentHref = this.state.originalHref;
this._log('Restored to original favicon href.');
} else {
// No original tag was present (this.hadOriginalLinkTag is false),
// or originalHref was null/empty.
// Remove the script-managed element if it exists and we likely created it.
if (this.managedElement && !this.hadOriginalLinkTag) {
this._log('No original tag, removing script-managed favicon element.');
if (this.managedElement.parentElement) {
this.managedElement.remove();
}
this.managedElement = null;
} else if (this.managedElement && this.hadOriginalLinkTag && !this.state.originalHref) {
// Original tag existed but href was null/empty. If we are managing it, clear its href or remove if script-created.
// For simplicity, if originalHref is effectively none, and we have a managedElement, remove it if we think we made it.
// Or, if it was the original empty-href tag, what to do? For now, if originalHref is falsey, we clear out.
this._log('Original tag had null/empty href, or originalHref is null. Removing current managed element if it exists.');
if (this.managedElement.parentElement) {
this.managedElement.remove();
}
this.managedElement = null;
} else {
this._log('No original tag and no script-managed element to remove, or original tag is being kept as is.');
}
this.state.currentHref = null; // No specific favicon href after restore in this case.
}
this.state.isUpdating = false;
this._log('Favicon restoration process complete.', { currentHref: this.state.currentHref, managedElement: !!this.managedElement });
}
}
class TabTitle {
constructor(apiKey, domainId) {
console.log("TabTitle CONSTRUCTOR CALLED - Checkpoint 1"); // Unconditional log
if (!apiKey) {
console.error("TabTitle: API key is required");
return;
}
if (!domainId) {
console.error("TabTitle: Domain ID is required");
return;
}
this.apiKey = apiKey;
this.domainId = domainId;
this.sessionId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : this._generateSimpleUUID();
this.config = {};
this.messages = [];
this.isPageVisible = !document.hidden;
this.originalTitle = document.title;
this.veryFirstOriginalTitle = document.title;
this.currentMessageTimeout = null;
this.sequences = [];
this.lastState = null;
this.faviconRules = [];
this.currentFaviconRule = null;
this.faviconInterval = null;
this.faviconTimeout = null;
this.inactiveAt = null;
this.debug = true;
this.apiBaseUrl = 'http://tabtitle.io';
// Initialize managers
this.stateManager = new StateManager();
this.stateManager.initialize();
this.faviconManager = new FaviconManager();
this.faviconManager.initialize();
this.sequenceManager = new SequenceManager();
this.sequenceManager.setItemStartHandler(this._handleSequenceItem.bind(this));
this.sequenceManager.setSequenceCompleteHandler(this._handleSequenceComplete.bind(this));
// Restore saved states
this._restoreSavedStates();
// Set up title observer before initializing
const titleObserver = new MutationObserver(() => {
if (document.title !== this.originalTitle && !this.currentMessageTimeout) {
this.originalTitle = document.title;
}
});
titleObserver.observe(document.querySelector('title') || document.head, {
subtree: true,
characterData: true,
childList: true
});
this.originalFavicon = this._getFaviconUrl();
document.addEventListener(
"visibilitychange",
this._handleVisibilityChange.bind(this)
);
// Set up periodic state saving
setInterval(() => {
this._saveCurrentStates();
}, 5000); // Save states every 5 seconds
this._fetchMessages();
this._fetchFaviconRules();
setInterval(() => {
this._fetchMessages();
this._fetchFaviconRules();
}, 5 * 60 * 1000);
this._log('TabTitle Initialized with API Key:', apiKey, 'Domain ID:', this.domainId);
}
_log(message, ...args) {
if (this.debug) {
console.log(`[TabTitle] ${message}`, ...args);
}
}
_generateSimpleUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
_setupEventListeners() {
document.addEventListener('visibilitychange', this._handleVisibilityChange.bind(this));
window.addEventListener('beforeunload', this._saveCurrentStates.bind(this));
}
_restoreSavedStates() {
// Restore sequence state
const sequenceState = this.stateManager.loadSequenceState();
if (sequenceState && this.sequenceManager) {
this.sequenceManager.setState(sequenceState);
if (sequenceState.isRunning && !this.isPageVisible) {
// Resume sequence if it was running
this.sequenceManager.resume();
}
}
// Restore favicon state
const faviconState = this.stateManager.loadFaviconState();
if (faviconState && this.faviconManager) {
this.faviconManager.state = faviconState;
if (faviconState.currentFavicon && !this.isPageVisible) {
this.faviconManager.setFavicon(faviconState.currentFavicon);
}
}
// Restore user preferences
const userPrefs = this.stateManager.loadUserPreferences();
if (userPrefs) {
// Apply user preferences (animation speeds, delays, etc.)
this._applyUserPreferences(userPrefs);
}
}
_saveCurrentStates() {
if (this.sequenceManager) {
this.stateManager.saveSequenceState(this.sequenceManager.getState());
}
if (this.faviconManager) {
this.stateManager.saveFaviconState(this.faviconManager.state);
}
}
_applyUserPreferences(prefs) {
if (prefs.defaultDelay) {
this.sequenceManager.defaultDelay = prefs.defaultDelay;
}
// Add more preference applications as needed
}
_handleVisibilityChange() {
this._log('Visibility changed. New document.hidden state:', document.hidden, 'Previous this.isPageVisible:', this.isPageVisible);
const newVisibility = !document.hidden;
this.isPageVisible = newVisibility; // Update the internal state first
if (!this.isPageVisible) { // Tab became hidden (document.hidden is true)
this._log('[TabTitle] Handling HIDDEN state because this.isPageVisible is now false.');
this.inactiveAt = Date.now();
this.sendAnalyticsEvent('TAB_INACTIVE');
this.sequenceManager.pause();
this._saveCurrentStates();
this._applyFaviconRules();
this._startApplicableSequence();
// Fallback to standalone messages if no sequence is now running
if (!this.sequenceManager.state.isRunning) {
this._startTitleChange();
}
} else { // Tab became visible
this._log('[TabTitle] Handling VISIBLE state because this.isPageVisible is now true.');
if (this.inactiveAt) {
const timeAwayMs = Date.now() - this.inactiveAt;
this.sendAnalyticsEvent('TAB_REENGAGED', { timeAwayMs });
this.inactiveAt = null;
}
this.sequenceManager.resume();
this._log(`[TabTitle] VISIBLE: Restoring title to veryFirstOriginalTitle: "${this.veryFirstOriginalTitle}". Current document.title: "${document.title}"`);
document.title = this.veryFirstOriginalTitle; // Use the snapshot here
this._log(`[TabTitle] VISIBLE: Title after attempting restore: "${document.title}"`);
if (this.currentMessageTimeout) {
clearTimeout(this.currentMessageTimeout);
this.currentMessageTimeout = null;
}
this._stopFaviconChange();
}
}
_applyFaviconConfig(rule, source = 'rule') {
console.log('Applying favicon config:', rule, 'Source:', source);
if (!rule || !rule.favicon_config) {
console.warn('Invalid favicon config:', rule);
return;
}
const config = rule.favicon_config;
if (!config.type) {
console.warn('Missing favicon type:', config);
return;
}
try {
if (config.type === 'custom' && config.customIconUrl) {
console.log('Setting custom favicon:', config.customIconUrl);
this.faviconManager.setFavicon(config.customIconUrl);
} else if (config.type === 'emoji' && config.emoji) {
console.log('Creating emoji favicon:', config.emoji);
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get canvas context');
return;
}
// Clear canvas with transparency instead of white background
ctx.clearRect(0, 0, 32, 32);
// Draw emoji
ctx.font = '28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
try {
ctx.fillText(config.emoji, 16, 16);
const faviconUrl = canvas.toDataURL('image/png');
console.log('Generated emoji favicon URL:', faviconUrl.substring(0, 100) + '...');
this.faviconManager.setFavicon(faviconUrl);
} catch (err) {
console.error('Error drawing emoji:', err);
}
} else if (config.type === 'color' && config.colors && config.colors.length > 0) {
console.log('Setting up color favicon with colors:', config.colors);
let colorIndex = 0;
// Set initial color
this._setColorFavicon(config.colors[0]);
// Clear any existing interval
if (this.faviconInterval) {
clearInterval(this.faviconInterval);
}
// Set up color animation if we have multiple colors and timing
if (config.colors.length > 1 && config.timing) {
console.log('Starting color animation with timing:', config.timing);
this.faviconInterval = setInterval(() => {
colorIndex = (colorIndex + 1) % config.colors.length;
this._setColorFavicon(config.colors[colorIndex]);
}, config.timing);
}
} else {
console.warn('Unsupported favicon type or missing required properties:', config);
}
} catch (err) {
console.error('Error applying favicon config:', err);
}
if (source === 'rule' && rule.id) {
this.sendAnalyticsEvent('FAVICON_RULE_APPLIED', {
ruleId: rule.id,
faviconType: config.type,
delayTime: rule.delay_time || 0 // Assuming delay_time is in seconds
});
}
}
_setColorFavicon(color) {
console.log('Setting color favicon:', color);
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get canvas context for color favicon');
return;
}
ctx.fillStyle = color;
ctx.fillRect(0, 0, 32, 32);
try {
const faviconUrl = canvas.toDataURL('image/png');
console.log('Generated color favicon URL:', faviconUrl.substring(0, 100) + '...');
this.faviconManager.setFavicon(faviconUrl);
} catch (err) {
console.error('Error creating color favicon:', err);
}
}
_getFaviconUrl() {
const favicon = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]');
return favicon ? favicon.href : null;
}
_setFavicon(url) {
if (!url) return;
this.faviconManager.setFavicon(url, {
delay: 0,
cacheBust: true
});
}
_restoreOriginalFavicon() {
if (this.faviconManager.state.original) {
this.faviconManager.restore();
}
}
_createColorFavicon(color) {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
ctx.fillStyle = color;
ctx.fillRect(0, 0, 32, 32);
return canvas.toDataURL();
}
async _fetchFaviconRules() {
if (!this.apiKey) {
this._log("FaviconRules: API Key not set, skipping fetch.");
return;
}
this._log("Fetching favicon rules...");
try {
const response = await fetch(`${this.apiBaseUrl}/api/favicon-rules`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`
}
});
if (!response.ok) {
let errorText = 'Unknown error';
try {
errorText = await response.text();
} catch (e) { /* ignore */ }
this._log(`Error fetching favicon rules. Status: ${response.status}, Text: ${errorText}`);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (!data || !data.rules) {
this._log('Favicon rules data is not in expected format:', data);
this.faviconRules = [];
return;
}
this.faviconRules = data.rules || []; // Corrected from data.faviconRules to data.rules
this._log(`Fetched favicon rules count: ${this.faviconRules.length}`);
// Send analytics event for favicon rules loaded
this.sendAnalyticsEvent('CONFIG_FAVICON_RULES_LOADED', {
ruleCount: this.faviconRules.length
});
// If page is hidden, apply rules
if (document.hidden) {
this._applyFaviconRules();
}
} catch (error) {
console.error("TabTitle: Failed to fetch favicon rules:", error);
this.faviconRules = []; // Ensure it's an array on error
}
}
_checkFaviconConditions(rule) {
const currentPath = window.location.pathname;
let conditionsMet = true;
let reason = '';
this._log(`Checking favicon rule: ${rule.name || rule.id}, path: ${rule.path}, schedule:`, rule.schedule, `against current path: ${currentPath}`);
// this._log(`Checking favicon rule: ${rule.name || rule.id}, path: ${rule.path}, schedule: ${JSON.stringify(rule.schedule)} against current path: ${currentPath}`);
// Check path first, independent of schedule
if (rule.path && rule.path !== '/') {
if (!currentPath.startsWith(rule.path)) {
reason = `Path condition failed. Rule path: ${rule.path}, current path: ${currentPath}`;
return false;
}
}
// If no schedule, we've already checked the path
if (!rule.schedule) {
this._log(`Favicon rule ${rule.name || rule.id} matched (no schedule, path check passed or not applicable).`);
return true;
}
const now = new Date();
// Check time range conditions
if (rule.schedule.timeRanges?.length > 0) {
const currentTime = now.getHours() * 60 + now.getMinutes();
const matchesTime = rule.schedule.timeRanges.some((range) => {
const [start, end] = range.split("-");
const [startHour, startMinute] = start.split(":").map(Number);
const [endHour, endMinute] = end.split(":").map(Number);
const startTime = startHour * 60 + startMinute;
const endTime = endHour * 60 + endMinute;
return currentTime >= startTime && currentTime <= endTime;
});
if (!matchesTime) return false;
}
// Check days of week conditions
if (rule.schedule.daysOfWeek?.length > 0) {
if (!rule.schedule.daysOfWeek.includes(now.getDay())) {
reason = `Day of week condition failed. Rule days of week: ${rule.schedule.daysOfWeek.join(', ')}, current day: ${now.getDay()}`;
return false;
}
}
// Check date range conditions
if (rule.schedule.dateRanges?.length > 0) {
const currentDate = now.toISOString().split("T")[0];
const matchesDate = rule.schedule.dateRanges.some((range) => {
const [start, end] = range.split("__");
return currentDate >= start && currentDate <= end;
});
if (!matchesDate) return false;
}
this._log(`Favicon rule ${rule.name || rule.id} matched all conditions.`);
return true;
}
_applyFaviconRules() {
this._log('[TabTitle] Attempting to apply favicon rules.');
if (this.isPageVisible) {
this._log('[TabTitle] Page is visible, skipping applyFaviconRules.');
return;
}
// Clear any existing animation
if (this.faviconInterval) {
clearInterval(this.faviconInterval);
this.faviconInterval = null;
}
if (this.faviconTimeout) {
clearTimeout(this.faviconTimeout);
this.faviconTimeout = null;
}
// Find ALL matching rules and sort by priority
const matchingRules = this.faviconRules
.filter(rule => this._checkFaviconConditions(rule))
.sort((a, b) => b.priority - a.priority);
this._log(`After filtering, ${matchingRules.length} favicon rules match.`);
if (matchingRules.length === 0) {
// Restore original favicon if no rules match
if (this.faviconManager.state.original) {
this._setFavicon(this.faviconManager.state.original.url);
}
return;
}
// Get the highest priority
const highestPriority = matchingRules[0].priority;
// Get all rules with the highest priority
const highestPriorityRules = matchingRules.filter(rule => rule.priority === highestPriority);
if (highestPriorityRules.length === 1) {
// If only one rule, apply it normally
this._applySingleFaviconRule(highestPriorityRules[0], this.faviconManager.state);
} else {
// If multiple rules with same priority, cycle between them
this._cycleMultipleFaviconRules(highestPriorityRules, this.faviconManager.state);
}
}
_applySingleFaviconRule(rule, state) {
if (!rule || !rule.favicon_config) return;
// this._log('[TabTitle] Applying single favicon rule:', rule);
this._applyFaviconConfig(rule, 'rule');
state.lastAppliedRuleIndex = state.rules.indexOf(rule);
state.lastAppliedTime = Date.now();
// If there's a timing for this specific rule (e.g. for temporary favicons in a sequence)
// or if it's a cycle and has a duration, set a timeout to restore or move to the next.
// This part might need integration with how sequence step durations are handled if a favicon rule is part of a sequence step.
const ruleDuration = rule.favicon_config.timing || (state.isCycling ? state.cycleDuration : null);
if (ruleDuration && ruleDuration > 0) {
this._log(`Rule ${rule.name} has duration ${ruleDuration}. Scheduling restore/next.`);
state.currentRuleTimeout = setTimeout(() => {
if (state.isCycling) {
this._log(`Cycling from rule ${rule.name}.`);
this._cycleMultipleFaviconRules(state.rules, state); // Move to next or loop
} else {
this._log(`Duration for rule ${rule.name} ended. Restoring original.`);
this.faviconManager.restore();
}
}, ruleDuration);
}
}
_cycleMultipleFaviconRules(rules, state) {
let currentIndex = 0;
let lastExecutionTime = 0;
const scheduleNextRule = (isFirst = false) => {
const rule = rules[currentIndex];
const nextIndex = (currentIndex + 1) % rules.length;
// For first iteration, apply the initial delay if it exists
const delay = isFirst && rule.delay_time ? rule.delay_time * 1000 : 0;
// Calculate actual timing based on last execution
const now = Date.now();
const timeSinceLastExecution = lastExecutionTime ? now - lastExecutionTime : 0;
const adjustedDelay = Math.max(0, delay - timeSinceLastExecution);
// Schedule the current rule
this.faviconTimeout = setTimeout(() => {
if (!this.isPageVisible) {
if (document.hidden) {
this._log(`Cycling to favicon rule: ${rule.name || rule.id}`, { ruleConfig: rule.favicon_config, delay: adjustedDelay });
}
this._applyFaviconConfig(rule, 'rule');
lastExecutionTime = Date.now();
// Schedule the next rule after the current rule's timing
const timing = rule.favicon_config.timing || 1000;
currentIndex = nextIndex;
// Clear existing timeout before creating new one
if (this.faviconTimeout) {
clearTimeout(this.faviconTimeout);
}
// Schedule next rule with its timing (not delay, as delays only apply to first iteration)
this.faviconTimeout = setTimeout(() => {
scheduleNextRule(false);
}, timing);
}
}, adjustedDelay);
};
// Clear any existing timeouts and intervals
if (this.faviconInterval) {
clearInterval(this.faviconInterval);
this.faviconInterval = null;
}
if (this.faviconTimeout) {
clearTimeout(this.faviconTimeout);
this.faviconTimeout = null;
}
// Start the sequence
scheduleNextRule(true);
}
_startFaviconChange() {
if (this.isPageVisible) return;
console.log('Starting favicon change');
// Clear any existing animations first
if (this.faviconInterval) {
clearInterval(this.faviconInterval);
this.faviconInterval = null;
}
if (this.faviconTimeout) {
clearTimeout(this.faviconTimeout);
this.faviconTimeout = null;
}
// Find matching rules
const matchingRules = this.faviconRules
.filter(rule => this._checkFaviconConditions(rule))
.sort((a, b) => b.priority - a.priority);
if (matchingRules.length === 0) return;
// Get highest priority rules
const highestPriority = matchingRules[0].priority;
const highestPriorityRules = matchingRules.filter(rule => rule.priority === highestPriority);
// Set customActive flag before applying rules
this.faviconManager.state.customActive = true;
// Apply rules
if (highestPriorityRules.length === 1) {
this._applySingleFaviconRule(highestPriorityRules[0], this.faviconManager.state);
} else {
this._cycleMultipleFaviconRules(highestPriorityRules, this.faviconManager.state);
}
}
_stopFaviconChange() {
// Clear any animations
if (this.faviconInterval) {
clearInterval(this.faviconInterval);
this.faviconInterval = null;
}
if (this.faviconTimeout) {
clearTimeout(this.faviconTimeout);
this.faviconTimeout = null;
}
// Update elapsed time if a delay was active
if (this.faviconManager.state.isDelayActive && this.faviconManager.state.startTime) {
this.faviconManager.state.delayElapsed = Date.now() - this.faviconManager.state.startTime;
}
// Always restore the original favicon when stopping
this._restoreOriginalFavicon();
}
async _fetchMessages() {
try {
const response = await fetch(`${this.apiBaseUrl}/api/messages`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`
}
});
if (!response.ok) {
let errorText = 'Unknown error';
try {
errorText = await response.text();
} catch (e) { /* ignore */ }
this._log(`Error fetching messages. Status: ${response.status}, Text: ${errorText}`);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Ensure data and data.messages are valid before proceeding
if (!data || !Array.isArray(data.messages)) {
this._log('Messages data is not in expected format or messages array is missing:', data);
this.messages = [];
this.sequences = [];
// Optionally send an analytics event for failed/malformed config load
this.sendAnalyticsEvent('CONFIG_MESSAGES_LOADED', {
messageCount: 0,
sequenceCount: 0,
standaloneMessageCount: 0,
error: 'Malformed data'
});
return;
}
this._log(`Fetched messages count: ${data.messages.length}`);
// Process messages and sequences
const processedData = this._processSequenceData(data.messages);
this.messages = processedData.standaloneMessages || [];
this.sequences = processedData.groupedSequences || [];
this._log(`Processed standalone messages count: ${this.messages.length}`);
this._log(`Processed sequences for SequenceManager count: ${this.sequences.length}`);
// Send analytics event for messages loaded
this.sendAnalyticsEvent('CONFIG_MESSAGES_LOADED', {
messageCount: data.messages.length, // Total raw messages fetched
sequenceCount: this.sequences.length,
standaloneMessageCount: this.messages.length
});
// Only start animations if page is hidden
if (document.hidden) {
this._startTitleChange(); // For standalone messages
this._startApplicableSequence(); // For sequences
}
} catch (error) {
console.error("TabTitle: Failed to fetch messages:", error);
this.messages = [];
this.sequences = [];
// Optionally send an analytics event for fetch error
this.sendAnalyticsEvent('CONFIG_MESSAGES_LOADED', {
messageCount: 0,
sequenceCount: 0,
standaloneMessageCount: 0,
error: 'Fetch error'
});
}
}
_processSequenceData(allMessages) {
if (!Array.isArray(allMessages)) {
this._log('Error: _processSequenceData expected an array, got:', allMessages);
return { standaloneMessages: [], groupedSequences: [] };
}
const standaloneMessages = [];
const sequenceMap = new Map();
allMessages.forEach(msg => {
if (msg.sequence_id) {
if (!sequenceMap.has(msg.sequence_id)) {
// Initialize sequence metadata.
// This assumes sequence-level properties like path or conditions
// would be present on each message belonging to the sequence,
// or would need to be fetched separately if sequences are distinct entities.
// For now, metadata is minimal.
sequenceMap.set(msg.sequence_id, {
metadata: {
groupId: msg.sequence_id,
// TODO: Determine how sequence-wide conditions, path, priority, etc. are defined.
// For now, these will be derived from the first message or assumed to be handled by item conditions.
// path: msg.conditions?.paths?.[0], // Example: take path from first message
// conditions: msg.conditions // Example: take conditions from first message
},
items: []
});
}
// Structure items as expected by SequenceManager.start() and _handleSequenceItem
sequenceMap.get(msg.sequence_id).items.push({
id: msg.id, // Message ID
type: 'message', // All sequence items are 'message' type for now
content: { message: { content: msg.content } }, // Nested structure for _handleSequenceItem
delay: (msg.delay_time || 0) * 1000, // SM expects milliseconds
duration: (msg.duration || 0) * 1000, // SM expects milliseconds
originalMessage: msg // Keep original message for access to all its properties (like sequence_order)
});
} else {
standaloneMessages.push(msg);
}
});
// Sort items within each sequence by sequence_order
sequenceMap.forEach(seq => {
seq.items.sort((a, b) => (a.originalMessage.sequence_order || 0) - (b.originalMessage.sequence_order || 0));
});
const groupedSequences = Array.from(sequenceMap.values());
this._log('Processed standalone messages:', standaloneMessages.length);
this._log('Processed grouped sequences:', groupedSequences.length, groupedSequences.map(s => ({id: s.metadata.groupId, items: s.items.length }) ));
return { standaloneMessages, groupedSequences };
}
_checkSequenceConditions(sequence) {
if (!sequence.metadata.conditions && !sequence.metadata.path) {
return true;
}
const currentPath = window.location.pathname;
// Check path first
if (sequence.metadata.path && !currentPath.startsWith(sequence.metadata.path)) {
return false;
}
// If no conditions, path check was sufficient
if (!sequence.metadata.conditions) {
return true;
}
return this._checkConditions({ conditions: sequence.metadata.conditions });
}
_stopTitleChange() {
document.title = this.veryFirstOriginalTitle;
if (this.currentMessageTimeout) {
clearTimeout(this.currentMessageTimeout);
this.currentMessageTimeout = null;
}
}
_checkConditions(message) {
if (!message.conditions) return true;
const now = new Date();
const currentPath = window.location.pathname;
const messageIdStr = message.id || 'N/A';
const timeStr = now.toLocaleTimeString();
const dayStr = now.getDay();
const dateStr = now.toISOString().split('T')[0];
const logMsg = `Checking conditions for message ID ${messageIdStr} against path: ${currentPath}, time: ${timeStr}, day: ${dayStr}, date: ${dateStr}`;
this._log(logMsg);
this._log('Message conditions:', message.conditions);
// this._log('Message conditions:', JSON.stringify(message.conditions));
// Check path conditions
if (message.conditions.paths?.length > 0) {
const matchesPath = message.conditions.paths.some(
(path) => currentPath === path || currentPath.startsWith(path)
);
if (!matchesPath) return false;
}
// Check time range conditions
if (message.conditions.timeRanges?.length > 0) {
const currentTime = now.getHours() * 60 + now.getMinutes();
const matchesTime = message.conditions.timeRanges.some((range) => {
const [start, end] = range.split("-");
const [startHour, startMinute] = start.split(":").map(Number);
const [endHour, endMinute] = end.split(":").map(Number);
const startTime = startHour * 60 + startMinute;
const endTime = endHour * 60 + endMinute;
return currentTime >= startTime && currentTime <= endTime;
});
if (!matchesTime) return false;
}
// Check days of week conditions
if (message.conditions.daysOfWeek?.length > 0) {
if (!message.conditions.daysOfWeek.includes(now.getDay())) {
return false;
}
}
// Check date range conditions
if (message.conditions.dateRanges?.length > 0) {
const currentDate = now.toISOString().split("T")[0];
const matchesDate = message.conditions.dateRanges.some((range) => {
const [start, end] = range.split("__");
return currentDate >= start && currentDate <= end;
});
if (!matchesDate) return false;
}
this._log(`Message ID ${message.id || 'N/A'} matched all conditions.`);
return true;
}
_startTitleChange() {
if (!document.hidden) {
this._log('Page is visible, not starting title change');
this._stopTitleChange();
return;
}
// If we have an active sequence, don't start regular messages
if (this.sequenceManager.state.isRunning) {
this._log('Sequence is running, not starting standalone title messages');
return;
}
if (this.currentMessageTimeout) {
this._log('Title change already in progress for a standalone message.');
return;
}
this._log('Attempting to start title change for standalone messages');
const validMessages = this.messages
.filter((msg) => this._checkConditions(msg))
.sort((a,b) => (b.priority || 0) - (a.priority || 0));
if (validMessages.length > 0) {
const messageToDisplay = {
content: validMessages[0].content,
duration: validMessages[0].duration || 0,
delay: validMessages[0].delay || 0
};
this._displayMessageDirectly(messageToDisplay);
} else {
this._log('No valid standalone messages to display.');
}
}
_displayMessageDirectly(messageConfig, sourceInfo = null) {
this._log(`DM_ENTRY: document.hidden is ${document.hidden}, isPageVisible is ${this.isPageVisible}. Source:`, sourceInfo, `Current title: "${document.title}"`, `Msg to display: "${messageConfig.content}"`);
// Outer check: Should we even attempt to display?
if (this.isPageVisible && (!sourceInfo || sourceInfo.source !== 'sequence_visible_override')) {
this._log('DM_OUTER_IF_TRUE: Page is visible and not forced. Not displaying message.', { content: messageConfig.content });
this._stopTitleChange(); // Restore original title
return;
}
this._log('DM_OUTER_IF_FALSE: Proceeding (page hidden or display forced). ', { content: messageConfig.content });
if (this.currentMessageTimeout) {
clearTimeout(this.currentMessageTimeout);
this.currentMessageTimeout = null;
}
const displayLogic = () => {
this._log(`DM_DISPLAYLOGIC_ENTRY: document.hidden is ${document.hidden}, isPageVisible is ${this.isPageVisible}.`, { content: messageConfig.content });
// Inner check: Re-validate if page is still hidden or forced at the moment of actual display
if (this.isPageVisible && (!sourceInfo || sourceInfo.source !== 'sequence_visible_override')) {
this._log("DM_INNER_IF_TRUE: Page became visible (or was visible) and not forced from within displayLogic. Not displaying.", { content: messageConfig.content });
// Only restore original title if it was a standalone message timer that got interrupted by visibility
// For sequences, the main visibility handler (_handleVisibilityChange) handles title restoration.
if (sourceInfo && sourceInfo.type === 'standalone_message_timer' && this.veryFirstOriginalTitle !== null) {
document.title = this.veryFirstOriginalTitle;
this._log("DM_INNER_IF_TRUE: Restored very first original title (standalone message interrupted by visibility).");
}
return;
}
this._log(`DM_INNER_IF_FALSE: Setting title to: "${messageConfig.content}". Current document.title: "${document.title}"`);
document.title = messageConfig.content;
this.currentMessage = messageConfig.content;
this._log(`DM_INNER_IF_FALSE: Title after setting: "${document.title}"`);
// Send analytics event for message displayed
if (sourceInfo && sourceInfo.type === 'sequence_message') {
this.sendAnalyticsEvent('SEQUENCE_MESSAGE_DISPLAYED', {
sequenceId: sourceInfo.sequenceId,
messageId: sourceInfo.messageId,
messageContent: typeof messageConfig.content === 'string' ? messageConfig.content.substring(0, 50) : 'N/A'
});
} else if (sourceInfo && sourceInfo.type === 'standalone_message') {
// Potentially a different event for standalone messages if needed
}
if (messageConfig.duration && messageConfig.duration > 0) {
this._log(`DM_SETTING_DURATION_TIMEOUT: Message will display for ${messageConfig.duration}ms.`, { content: messageConfig.content });
if (this.messageClearTimer) {
clearTimeout(this.messageClearTimer);
}
this.messageClearTimer = setTimeout(() => {
// Only restore if current title is still the one we set AND the page is still hidden (or was hidden when this timer was set)
// If page became visible, _handleVisibilityChange would have already restored the title.
if (document.title === messageConfig.content && !this.isPageVisible) {
this._log(`DM_DURATION_ENDED_HIDDEN: Restoring very first original title after ${messageConfig.duration}ms timeout as page is still hidden.`, { content: messageConfig.content });
document.title = this.veryFirstOriginalTitle;
} else if (document.title === messageConfig.content && this.isPageVisible) {
this._log(`DM_DURATION_ENDED_VISIBLE: Message duration ended but page is now visible. Title should have been restored by visibility handler.`);
}
this.messageClearTimer = null;
this.currentMessage = null;
}, messageConfig.duration);
}
};
if (messageConfig.delay && messageConfig.delay > 0) {
this._log('DM_DELAYING_MESSAGE_DISPLAY:', { content: messageConfig.content, delay: messageConfig.delay });
this.currentMessageTimeout = setTimeout(displayLogic, messageConfig.delay);
} else {
this._log('DM_CALLING_DISPLAYLOGIC_DIRECTLY:', { content: messageConfig.content });
displayLogic();
}
}
_handleSequenceItem(item) {
this._log('TabTitle._handleSequenceItem received item from SequenceManager:', item);
// this._log('TabTitle._handleSequenceItem received item from SequenceManager:', JSON.stringify(item, null, 2));
if (!item || !item.type || !item.content) {
this._log('TabTitle._handleSequenceItem: Received invalid or incomplete item structure.', item);
return;
}
const currentSequenceMetadata = this.sequenceManager.getState().sequenceMetadata;
if (item.type === 'message' && item.content.message) {
const messageConfig = {
content: item.content.message.content,
duration: item.duration || 0
};
this._log('TabTitle._handleSequenceItem calling _displayMessageDirectly for message:', messageConfig);
this._displayMessageDirectly(
{ ...messageConfig, delay: 0 },
{
type: 'sequence_message',
sequenceId: currentSequenceMetadata?.groupId,
messageId: item.id
}
);
} else if (item.type === 'favicon' && item.content.favicon) {
// This branch is currently not expected to be hit if favicons are not part of SequenceManager sequences
this._log('TabTitle._handleSequenceItem received a favicon item. This is unexpected for current design.');
const faviconConfig = {
type: item.content.favicon.type,
colors: item.content.favicon.type === 'color' ? [item.content.favicon.value] : undefined,
emoji: item.content.favicon.type === 'emoji' ? item.content.favicon.value : undefined,
customIconUrl: item.content.favicon.type === 'custom' ? item.content.favicon.value : undefined,
timing: item.content.favicon.timing || 1000
};
this._applyFaviconConfig({ id: item.id, favicon_config: faviconConfig, delay_time: item.delay / 1000 }, 'sequence_favicon');
}
}
_handleSequenceComplete(metadata) {
console.log('Sequence completed:', metadata);
if (this.isPageVisible) {
this._stopTitleChange();
this._stopFaviconChange();
}
}
_removeFavicon() {
try {
const existingFavicons = document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]');
existingFavicons.forEach(favicon => {
if (favicon && favicon.parentNode) {
try {
favicon.parentNode.removeChild(favicon);
} catch (e) {
console.warn('Error removing favicon:', e);
}
}
});
} catch (e) {
console.warn('Error in _removeFavicon:', e);
}
}
async sendAnalyticsEvent(eventType, details = {}) {
if (!this.apiKey) {
this._log('Analytics: API Key not set, skipping event.');
return;
}
const payload = {
domainApiKey: this.apiKey,
eventType: eventType,
path: window.location.pathname + window.location.search,
sessionId: this.sessionId,
...details
};
this._log('Sending analytics event:', payload);
try {
const response = await fetch(`${this.apiBaseUrl}/api/v1/analytics/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response' }));
this._log('Analytics: Error sending event:', response.status, errorData);
} else {
const successData = await response.json().catch(() => ({ message: 'Failed to parse success response'}));
this._log('Analytics: Event sent successfully:', successData);
}
} catch (error) {
this._log('Analytics: Network or other error sending event:', error);
}
}
_startApplicableSequence() {
this._log('[TabTitle] Attempting to start applicable sequence.');
if (this.sequenceManager.state.isRunning) {
this._log('SequenceManager is already running. Not starting another sequence.');
return;
}
const validSequences = this.sequences.filter(seq => this._checkSequenceConditions(seq));
this._log(`Found ${validSequences.length} valid sequences to potentially start.`);
if (validSequences.length > 0) {
// For now, just start the first valid sequence found.
// TODO: Implement priority if multiple sequences can be valid.
const sequenceToStart = validSequences[0];
this._log('Attempting to start sequence with Group ID:', sequenceToStart.metadata.groupId, 'Item count:', sequenceToStart.items.length);
// this._log('Attempting to start sequence with Group ID:', sequenceToStart.metadata.groupId, 'Details:', JSON.stringify(sequenceToStart, null, 2));
// Send analytics event for sequence started
this.sendAnalyticsEvent('SEQUENCE_STARTED', {
sequenceId: sequenceToStart.metadata.groupId,
itemCount: sequenceToStart.items.length,
// sequenceType: 'messageSequence' // Or derive from items if needed
});
this.sequenceManager.start(sequenceToStart.items, sequenceToStart.metadata);
} else {
this._log('No valid sequences found to start.');
}
}
}
window.TabTitle = TabTitle;