diff --git a/.circleci/config.yml b/.circleci/config.yml index 33c4df7110a..b36034fbd79 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,6 +15,12 @@ parameters: run_flaky_tests: type: boolean default: false + run_lint_only: + type: boolean + default: false + run_build_only: + type: boolean + default: false resource_class: type: enum enum: ["small", "medium", "medium+", "large", "xlarge", "2xlarge"] @@ -367,7 +373,7 @@ workflows: job: ["nogroup"] jobsize: ["1"] parallelism: [1] - scriptparameter: ["\\.pr"] + scriptparameter: ["\\.pr\\.js$"] run_flaky_tests: when: << pipeline.parameters.run_flaky_tests >> @@ -508,4 +514,14 @@ workflows: branches: only: remix_beta + lint_only: + when: << pipeline.parameters.run_lint_only >> + jobs: + - lint + + build_only: + when: << pipeline.parameters.run_build_only >> + jobs: + - build + # VS Code Extension Version: 1.5.1 diff --git a/.eslintrc.json b/.eslintrc.json index d6efa4afee5..e026dc1ceda 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -60,7 +60,24 @@ "object-curly-spacing": ["error", "always", { "arraysInObjects": false }], "no-trailing-spaces": "error", "no-multi-spaces": "error", - "no-multiple-empty-lines": ["error" , { "max": 1}] + "no-multiple-empty-lines": ["error" , { "max": 1}], + "no-restricted-syntax": [ + "error", + { + "selector": "MemberExpression[object.type='Identifier'][object.name='window'][property.type='Identifier'][property.name='_paq']", + "message": "Direct usage of window._paq is not allowed. Use one of these alternatives instead:\n 1. TrackingContext: track(eventBuilder)\n 2. MatomoManager: matomoManager.trackEvent(...)\n 3. Matomo Plugin: plugin.call('matomo', 'track', ...)\n 4. Helper: trackMatomoEvent(plugin, eventBuilder)" + }, + { + "selector": "MemberExpression[object.type='MemberExpression'][object.object.type='Identifier'][object.object.name='window'][object.property.type='Identifier'][object.property.name='_paq']", + "message": "Direct usage of window._paq methods is not allowed. Use one of these alternatives instead:\n 1. TrackingContext: track(eventBuilder)\n 2. MatomoManager: matomoManager.trackEvent(...)\n 3. Matomo Plugin: plugin.call('matomo', 'track', ...)\n 4. Helper: trackMatomoEvent(plugin, eventBuilder)" + } + ] + } + }, + { + "files": ["**/src/app/matomo/*.ts", "**/src/assets/js/**/*.js"], + "rules": { + "no-restricted-syntax": "off" } }, { diff --git a/.gitignore b/.gitignore index 25ffc794462..991ebd45da2 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ apps/remix-ide-e2e/tmp/ # IDE - Cursor .cursor/ +PR_MESSAGE.md diff --git a/apps/circuit-compiler/src/app/services/circomPluginClient.ts b/apps/circuit-compiler/src/app/services/circomPluginClient.ts index daee5c96067..97fb7d16861 100644 --- a/apps/circuit-compiler/src/app/services/circomPluginClient.ts +++ b/apps/circuit-compiler/src/app/services/circomPluginClient.ts @@ -1,5 +1,6 @@ import { PluginClient } from '@remixproject/plugin' import { createClient } from '@remixproject/plugin-webview' +import { trackMatomoEvent, CircuitCompilerEvents } from '@remix-api' import EventManager from 'events' import pathModule from 'path' import { compiler_list, parse, compile, generate_r1cs, generate_witness } from 'circom_wasm' @@ -22,11 +23,23 @@ export class CircomPluginClient extends PluginClient { private lastCompiledFile: string = '' private compiler: typeof compilerV215 & typeof compilerV216 & typeof compilerV217 & typeof compilerV218 public _paq = { - push: (args) => { - this.call('matomo' as any, 'track', args) + push: (args: any[]) => { + if (args[0] === 'trackEvent' && args.length >= 3) { + // Convert legacy _paq.push(['trackEvent', 'category', 'action', 'name']) + // to matomo plugin call with legacy string signature + const [, category, action, name, value] = args; + this.call('matomo' as any, 'trackEvent', category, action, name, value); + } else { + // For other _paq commands, pass through as-is + console.warn('CircuitCompiler: Unsupported _paq command:', args); + } } } + private trackCircuitEvent = (event: ReturnType) => { + trackMatomoEvent(this, event); + } + constructor() { super() this.methods = ['init', 'parse', 'compile', 'generateR1cs', 'resolveDependencies'] @@ -175,7 +188,7 @@ export class CircomPluginClient extends PluginClient { const circuitErrors = circuitApi.report() this.logCompilerReport(circuitErrors) - this._paq.push(['trackEvent', 'circuit-compiler', 'compile', 'Compilation failed']) + this.trackCircuitEvent(CircuitCompilerEvents.compile('Compilation failed')) throw new Error(circuitErrors) } else { this.lastCompiledFile = path @@ -204,7 +217,7 @@ export class CircomPluginClient extends PluginClient { this.internalEvents.emit('circuit_parsing_done', parseErrors, filePathToId) this.emit('statusChanged', { key: 'succeed', title: 'circuit compiled successfully', type: 'success' }) } - this._paq.push(['trackEvent', 'circuit-compiler', 'compile', 'Compilation successful']) + this.trackCircuitEvent(CircuitCompilerEvents.compile('Compilation successful')) circuitApi.log().map(log => { log && this.call('terminal', 'log', { type: 'log', value: log }) }) @@ -286,7 +299,7 @@ export class CircomPluginClient extends PluginClient { const r1csErrors = r1csApi.report() this.logCompilerReport(r1csErrors) - this._paq.push(['trackEvent', 'circuit-compiler', 'generateR1cs', 'R1CS Generation failed']) + this.trackCircuitEvent(CircuitCompilerEvents.generateR1cs('R1CS Generation failed')) throw new Error(r1csErrors) } else { const fileName = extractNameFromKey(path) @@ -294,7 +307,7 @@ export class CircomPluginClient extends PluginClient { // @ts-ignore await this.call('fileManager', 'writeFile', writePath, r1csProgram, true) - this._paq.push(['trackEvent', 'circuit-compiler', 'generateR1cs', 'R1CS Generation successful']) + this.trackCircuitEvent(CircuitCompilerEvents.generateR1cs('R1CS Generation successful')) r1csApi.log().map(log => { log && this.call('terminal', 'log', { type: 'log', value: log }) }) @@ -342,7 +355,7 @@ export class CircomPluginClient extends PluginClient { const witness = this.compiler ? await this.compiler.generate_witness(dataRead, input) : await generate_witness(dataRead, input) // @ts-ignore await this.call('fileManager', 'writeFile', wasmPath.replace('.wasm', '.wtn'), witness, { encoding: null }) - this._paq.push(['trackEvent', 'circuit-compiler', 'computeWitness', 'compiler.generate_witness', wasmPath.replace('.wasm', '.wtn')]) + this.trackCircuitEvent(CircuitCompilerEvents.computeWitness(wasmPath.replace('.wasm', '.wtn'))) this.internalEvents.emit('circuit_computing_witness_done') this.emit('statusChanged', { key: 'succeed', title: 'witness computed successfully', type: 'success' }) return witness diff --git a/apps/contract-verification/src/app/views/LookupView.tsx b/apps/contract-verification/src/app/views/LookupView.tsx index f43f4940aea..f087d5e87f3 100644 --- a/apps/contract-verification/src/app/views/LookupView.tsx +++ b/apps/contract-verification/src/app/views/LookupView.tsx @@ -1,4 +1,5 @@ import { useContext, useEffect, useMemo, useState } from 'react' +import { trackMatomoEvent, ContractVerificationEvents } from '@remix-api' import { SearchableChainDropdown, ContractAddressInput } from '../components' import { mergeChainSettingsWithDefaults, validConfiguration } from '../utils' import type { LookupResponse, VerifierIdentifier } from '../types' @@ -59,14 +60,14 @@ export const LookupView = () => { } } - const sendToMatomo = async (eventAction: string, eventName: string) => { - await clientInstance.call('matomo' as any, 'track', ['trackEvent', 'ContractVerification', eventAction, eventName]) + const sendToMatomo = async (eventName: string) => { + await trackMatomoEvent(clientInstance, ContractVerificationEvents.lookup(eventName)); } const handleOpenInRemix = async (lookupResponse: LookupResponse) => { try { await clientInstance.saveToRemix(lookupResponse) - await sendToMatomo('lookup', 'openInRemix On: ' + selectedChain) + await sendToMatomo('openInRemix On: ' + selectedChain) } catch (err) { console.error(`Error while trying to open in Remix: ${err.message}`) } diff --git a/apps/contract-verification/src/app/views/VerifyView.tsx b/apps/contract-verification/src/app/views/VerifyView.tsx index a0ab2b75801..b5933fa9554 100644 --- a/apps/contract-verification/src/app/views/VerifyView.tsx +++ b/apps/contract-verification/src/app/views/VerifyView.tsx @@ -1,4 +1,5 @@ import { useContext, useEffect, useMemo, useState } from 'react' +import { trackMatomoEvent, ContractVerificationEvents } from '@remix-api' import { AppContext } from '../AppContext' import { SearchableChainDropdown, ContractDropdown, ContractAddressInput } from '../components' @@ -42,8 +43,8 @@ export const VerifyView = () => { setEnabledVerifiers({ ...enabledVerifiers, [verifierId]: checked }) } - const sendToMatomo = async (eventAction: string, eventName: string) => { - await clientInstance.call("matomo" as any, 'track', ['trackEvent', 'ContractVerification', eventAction, eventName]); + const sendToMatomo = async (eventName: string) => { + await trackMatomoEvent(clientInstance, ContractVerificationEvents.verify(eventName)); } const handleVerify = async (e) => { @@ -68,7 +69,7 @@ export const VerifyView = () => { name: verifierId as VerifierIdentifier, } receipts.push({ verifierInfo, status: 'pending', contractId, isProxyReceipt: false, failedChecks: 0 }) - await sendToMatomo('verify', `verifyWith${verifierId} On: ${selectedChain?.chainId} IsProxy: ${!!(hasProxy && proxyAddress)}`) + await sendToMatomo(`verifyWith${verifierId} On: ${selectedChain?.chainId} IsProxy: ${!!(hasProxy && proxyAddress)}`) } const newSubmittedContract: SubmittedContract = { diff --git a/apps/learneth/src/redux/models/remixide.ts b/apps/learneth/src/redux/models/remixide.ts index beeffc5a77b..8ed4f20f3a2 100644 --- a/apps/learneth/src/redux/models/remixide.ts +++ b/apps/learneth/src/redux/models/remixide.ts @@ -2,6 +2,7 @@ import { toast } from 'react-toastify' import { type ModelType } from '../store' import remixClient from '../../remix-client' import { router } from '../../App' +import { trackMatomoEvent, LearnethEvents } from '@remix-api' function getFilePath(file: string): string { const name = file.split('/') @@ -46,11 +47,28 @@ const Model: ModelType = { }, }); + // Type-safe Matomo tracking helper + const trackLearnethEvent = (event: ReturnType) => { + trackMatomoEvent(remixClient, event); + }; + + // Legacy _paq compatibility layer for existing learneth tracking calls (window as any)._paq = { - push: (args) => { - remixClient.call('matomo' as any, 'track', args) + push: (args: any[]) => { + if (args[0] === 'trackEvent' && args.length >= 3) { + // Convert legacy _paq.push(['trackEvent', 'category', 'action', 'name']) + // to matomo plugin call with legacy string signature + const [, category, action, name, value] = args; + remixClient.call('matomo' as any, 'trackEvent', category, action, name, value); + } else { + // For other _paq commands, pass through as-is + console.warn('Learneth: Unsupported _paq command:', args); + } } - } + }; + + // Make trackLearnethEvent available globally for the effects + (window as any).trackLearnethEvent = trackLearnethEvent; yield router.navigate('/home') }, @@ -74,7 +92,7 @@ const Model: ModelType = { return } - (window)._paq.push(['trackEvent', 'learneth', 'display_file', `${(step && step.name)}/${path}`]) + (window).trackLearnethEvent(LearnethEvents.displayFile(`${(step && step.name)}/${path}`)) toast.info(`loading ${path} into IDE`) yield put({ @@ -101,7 +119,7 @@ const Model: ModelType = { }) toast.dismiss() } catch (error) { - (window)._paq.push(['trackEvent', 'learneth', 'display_file_error', error.message]) + (window).trackLearnethEvent(LearnethEvents.displayFileError(error.message)) toast.dismiss() toast.error('File could not be loaded. Please try again.') yield put({ @@ -151,7 +169,7 @@ const Model: ModelType = { type: 'remixide/save', payload: { errors: ['Compiler failed to test this file']}, }); - (window)._paq.push(['trackEvent', 'learneth', 'test_step_error', 'Compiler failed to test this file']) + (window).trackLearnethEvent(LearnethEvents.testStepError('Compiler failed to test this file')) } else { const success = result.totalFailing === 0; if (success) { @@ -167,14 +185,14 @@ const Model: ModelType = { }, }) } - (window)._paq.push(['trackEvent', 'learneth', 'test_step', success]) + (window).trackLearnethEvent(LearnethEvents.testStep(String(success))) } } catch (err) { yield put({ type: 'remixide/save', payload: { errors: [String(err)]}, }); - (window)._paq.push(['trackEvent', 'learneth', 'test_step_error', err]) + (window).trackLearnethEvent(LearnethEvents.testStepError(String(err))) } yield put({ type: 'loading/save', @@ -204,13 +222,13 @@ const Model: ModelType = { yield remixClient.call('fileManager', 'setFile', path, content) yield remixClient.call('fileManager', 'switchFile', `${path}`); - (window)._paq.push(['trackEvent', 'learneth', 'show_answer', path]) + (window).trackLearnethEvent(LearnethEvents.showAnswer(path)) } catch (err) { yield put({ type: 'remixide/save', payload: { errors: [String(err)]}, }); - (window)._paq.push(['trackEvent', 'learneth', 'show_answer_error', err.message]) + (window).trackLearnethEvent(LearnethEvents.showAnswerError(err.message)) } toast.dismiss() @@ -224,7 +242,7 @@ const Model: ModelType = { *testSolidityCompiler(_, { put, select }) { try { yield remixClient.call('solidity', 'getCompilationResult'); - (window)._paq.push(['trackEvent', 'learneth', 'test_solidity_compiler']) + (window).trackLearnethEvent(LearnethEvents.testSolidityCompiler()) } catch (err) { const errors = yield select((state) => state.remixide.errors) yield put({ @@ -233,7 +251,7 @@ const Model: ModelType = { errors: [...errors, "The `Solidity Compiler` is not yet activated.
Please activate it using the `SOLIDITY` button in the `Featured Plugins` section of the homepage."], }, }); - (window)._paq.push(['trackEvent', 'learneth', 'test_solidity_compiler_error', err.message]) + (window).trackLearnethEvent(LearnethEvents.testSolidityCompilerError(err.message)) } } }, diff --git a/apps/remix-ide-e2e/src/tests/matomo-bot-detection.test.ts b/apps/remix-ide-e2e/src/tests/matomo-bot-detection.test.ts new file mode 100644 index 00000000000..8632fc0fd1f --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/matomo-bot-detection.test.ts @@ -0,0 +1,309 @@ +'use strict' +import { NightwatchBrowser } from 'nightwatch' +import init from '../helpers/init' + +/** + * Matomo Bot Detection Tests + * + * These tests verify that: + * 1. Bot detection correctly identifies automation tools (Selenium/WebDriver) + * 2. The isBot custom dimension is set correctly in Matomo + * 3. Bot type and confidence are reported accurately + * 4. Events are still tracked but tagged with bot status + */ + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: VoidFunction) { + init(browser, done, 'http://127.0.0.1:8080', false) + }, + + // Enable Matomo on localhost for testing + 'Enable Matomo and wait for initialization': function (browser: NightwatchBrowser) { + browser + .execute(function () { + localStorage.setItem('showMatomo', 'true'); + }, []) + .refreshPage() + .waitForElementPresent({ + selector: `//*[@data-id='compilerloaded']`, + locateStrategy: 'xpath', + timeout: 120000 + }) + }, + + 'Load debug plugin before accepting consent': function (browser: NightwatchBrowser) { + browser + // Load debug plugin BEFORE accepting consent so it captures the bot detection event + .execute(function () { + const matomoManager = (window as any)._matomoManagerInstance; + if (!matomoManager) return { success: false, error: 'No MatomoManager' }; + + return new Promise((resolve) => { + matomoManager.loadDebugPluginForE2E().then((debugHelpers: any) => { + (window as any).__matomoDebugHelpers = debugHelpers; + resolve({ success: true }); + }).catch((error: any) => { + resolve({ success: false, error: error.message }); + }); + }); + }, [], (result: any) => { + browser.assert.ok(result.value.success, 'Debug plugin loaded before consent'); + }) + // Wait for debug plugin loaded marker + .waitForElementPresent({ + selector: `//*[@data-id='matomo-debug-plugin-loaded']`, + locateStrategy: 'xpath', + timeout: 5000 + }) + }, + + 'Accept consent to enable tracking': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible('*[data-id="matomoModalModalDialogModalBody-react"]') + .click('[data-id="matomoModal-modal-footer-ok-react"]') + .waitForElementNotVisible('*[data-id="matomoModalModalDialogModalBody-react"]') + // Wait for bot detection to complete + .waitForElementPresent({ + selector: `//*[@data-id='matomo-bot-detection-complete']`, + locateStrategy: 'xpath', + timeout: 5000 + }) + // Wait for full Matomo initialization + .waitForElementPresent({ + selector: `//*[@data-id='matomo-initialized']`, + locateStrategy: 'xpath', + timeout: 5000 + }) + }, + + 'Verify bot detection identifies automation tool': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const matomoManager = (window as any)._matomoManagerInstance; + if (!matomoManager) { + return { error: 'MatomoManager not found' }; + } + + const isBot = matomoManager.isBot(); + const botType = matomoManager.getBotType(); + const confidence = matomoManager.getBotConfidence(); + const fullResult = matomoManager.getBotDetectionResult(); + + return { + isBot, + botType, + confidence, + reasons: fullResult?.reasons || [], + userAgent: fullResult?.userAgent || navigator.userAgent + }; + }, [], (result: any) => { + console.log('🤖 Bot Detection Result:', result.value); + + // Selenium/WebDriver should be detected as a bot + browser.assert.strictEqual( + result.value.isBot, + true, + 'Selenium/WebDriver should be detected as a bot' + ); + + // Should detect automation with high confidence + browser.assert.strictEqual( + result.value.confidence, + 'high', + 'Automation detection should have high confidence' + ); + + // Bot type should indicate automation + const botType = result.value.botType; + const isAutomationBot = botType.includes('automation') || + botType.includes('webdriver') || + botType.includes('selenium'); + + browser.assert.strictEqual( + isAutomationBot, + true, + `Bot type should indicate automation, got: ${botType}` + ); + + // Log detection reasons for debugging + console.log('🔍 Detection reasons:', result.value.reasons); + }) + }, + + 'Verify isBot custom dimension is set in Matomo': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const matomoManager = (window as any)._matomoManagerInstance; + const botType = matomoManager.getBotType(); + + // Get the debug data to verify dimension was set + const debugData = (window as any).__getMatomoDimensions?.(); + + return { + botType, + dimensionsSet: debugData || {}, + hasDimension: debugData && Object.keys(debugData).length > 0 + }; + }, [], (result: any) => { + console.log('📊 Matomo Dimensions:', result.value); + + // Verify bot type is not 'human' + browser.assert.notStrictEqual( + result.value.botType, + 'human', + 'Bot type should not be "human" in E2E tests' + ); + + // If debug plugin is loaded, verify dimension is set + if (result.value.hasDimension) { + console.log('✅ Bot dimension found in debug data'); + } + }) + }, + + 'Verify events are tracked with bot detection': function (browser: NightwatchBrowser) { + browser + // Matomo already initialized (marker checked in previous test) + // Trigger a tracked event by clicking a plugin + .clickLaunchIcon('filePanel') + .pause(1000) // Small delay for event propagation + + .execute(function () { + const matomoManager = (window as any)._matomoManagerInstance; + const debugHelpers = (window as any).__matomoDebugHelpers; + + if (!debugHelpers) return { error: 'Debug helpers not found' }; + + const events = debugHelpers.getEvents(); + const isBot = matomoManager.isBot(); + const botType = matomoManager.getBotType(); + + // Find bot detection event + const botDetectionEvent = events.find((e: any) => + e.category === 'bot-detection' || e.e_c === 'bot-detection' + ); + + return { + isBot, + botType, + eventCount: events.length, + lastEvent: events[events.length - 1] || null, + isInitialized: matomoManager.getState().initialized, + hasBotDetectionEvent: !!botDetectionEvent, + botDetectionEvent: botDetectionEvent || null + }; + }, [], (result: any) => { + console.log('📈 Event Tracking Result:', result.value); + + // Verify Matomo is initialized + browser.assert.ok( + result.value.isInitialized, + 'Matomo should be initialized after delay' + ); + + // Verify events are being tracked + browser.assert.ok( + result.value.eventCount > 0, + `Events should be tracked even for bots (found ${result.value.eventCount})` + ); + + // Verify bot is still detected + browser.assert.strictEqual( + result.value.isBot, + true, + 'Bot status should remain true after event tracking' + ); + + // Verify bot detection event was sent + browser.assert.ok( + result.value.hasBotDetectionEvent, + 'Bot detection event should be tracked' + ); + + // Log bot detection event details + if (result.value.botDetectionEvent) { + console.log('🤖 Bot Detection Event:', { + category: result.value.botDetectionEvent.e_c || result.value.botDetectionEvent.category, + action: result.value.botDetectionEvent.e_a || result.value.botDetectionEvent.action, + name: result.value.botDetectionEvent.e_n || result.value.botDetectionEvent.name, + value: result.value.botDetectionEvent.e_v || result.value.botDetectionEvent.value + }); + } + + // Log last event details + if (result.value.lastEvent) { + console.log('📊 Last event:', { + category: result.value.lastEvent.e_c, + action: result.value.lastEvent.e_a, + name: result.value.lastEvent.e_n + }); + } + }) + }, + + 'Verify bot detection result has expected structure': function (browser: NightwatchBrowser) { + browser + .execute(function () { + const matomoManager = (window as any)._matomoManagerInstance; + const result = matomoManager.getBotDetectionResult(); + + return { + hasResult: result !== null, + hasIsBot: typeof result?.isBot === 'boolean', + hasBotType: typeof result?.botType === 'string' || result?.botType === undefined, + hasConfidence: ['high', 'medium', 'low'].includes(result?.confidence), + hasReasons: Array.isArray(result?.reasons), + hasUserAgent: typeof result?.userAgent === 'string', + // Also return actual values for logging + actualIsBot: result?.isBot, + actualBotType: result?.botType, + actualConfidence: result?.confidence, + actualReasons: result?.reasons, + actualUserAgent: result?.userAgent, + hasMouseAnalysis: !!result?.mouseAnalysis, + mouseMovements: result?.mouseAnalysis?.movements || 0, + humanLikelihood: result?.mouseAnalysis?.humanLikelihood || 'unknown' + }; + }, [], (result: any) => { + console.log('🔍 Bot Detection Structure:', result.value); + + browser.assert.strictEqual(result.value.hasResult, true, 'Should have bot detection result'); + browser.assert.strictEqual(result.value.hasIsBot, true, `Should have isBot boolean (value: ${result.value.actualIsBot})`); + browser.assert.strictEqual(result.value.hasBotType, true, `Should have botType string (value: ${result.value.actualBotType})`); + browser.assert.strictEqual(result.value.hasConfidence, true, `Should have valid confidence level (value: ${result.value.actualConfidence})`); + browser.assert.strictEqual(result.value.hasReasons, true, `Should have reasons array (count: ${result.value.actualReasons?.length || 0})`); + browser.assert.strictEqual(result.value.hasUserAgent, true, 'Should have userAgent string'); + + // Log mouse analysis if available + if (result.value.hasMouseAnalysis) { + browser.assert.ok(true, `đŸ–ąī¸ Mouse Analysis: ${result.value.mouseMovements} movements, likelihood: ${result.value.humanLikelihood}`); + } else { + browser.assert.ok(true, 'đŸ–ąī¸ Mouse Analysis: Not available (bot detected before mouse tracking)'); + } + }) + }, + + 'Verify navigator.webdriver flag is present': function (browser: NightwatchBrowser) { + browser + .execute(function () { + return { + webdriver: navigator.webdriver, + hasWebdriver: navigator.webdriver === true + }; + }, [], (result: any) => { + console.log('🌐 Navigator.webdriver:', result.value); + + // Selenium/WebDriver sets this flag + browser.assert.strictEqual( + result.value.hasWebdriver, + true, + 'navigator.webdriver should be true in Selenium/WebDriver' + ); + }) + }, + + 'Test complete': function (browser: NightwatchBrowser) { + browser.end() + } +} diff --git a/apps/remix-ide-e2e/src/tests/matomo-consent.test.ts b/apps/remix-ide-e2e/src/tests/matomo-consent.test.ts new file mode 100644 index 00000000000..e4fd617401e --- /dev/null +++ b/apps/remix-ide-e2e/src/tests/matomo-consent.test.ts @@ -0,0 +1,931 @@ +'use strict' +import { NightwatchBrowser } from 'nightwatch' +import init from '../helpers/init' + +// Helper 1: Fresh start - enable Matomo and wait for load +function startFreshTest(browser: NightwatchBrowser) { + return browser + .execute(function () { + // Clear all Matomo-related state for clean test + localStorage.removeItem('config-v0.8:.remix.config'); + localStorage.removeItem('matomo-analytics-consent'); + localStorage.setItem('showMatomo', 'true'); + // Clear cookies + document.cookie.split(";").forEach(function(c) { + document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); + }); + }, []) + .refreshPage() + .waitForElementPresent({ + selector: `//*[@data-id='compilerloaded']`, + locateStrategy: 'xpath', + timeout: 120000 + }); +} + +// Helper 2: Accept consent modal +function acceptConsent(browser: NightwatchBrowser) { + return browser + .waitForElementVisible('*[data-id="matomoModalModalDialogModalBody-react"]') + .click('[data-id="matomoModal-modal-footer-ok-react"]') + .waitForElementNotVisible('*[data-id="matomoModalModalDialogModalBody-react"]') + // Wait for bot detection and Matomo initialization + .waitForElementPresent({ + selector: `//*[@data-id='matomo-bot-detection-complete']`, + locateStrategy: 'xpath', + timeout: 5000 + }) + .waitForElementPresent({ + selector: `//*[@data-id='matomo-initialized']`, + locateStrategy: 'xpath', + timeout: 5000 + }) + .execute(function() { + // Verify Matomo initialization + const matomoManager = (window as any)._matomoManagerInstance; + return { + hasPaq: !!(window as any)._paq, + hasMatomo: !!(window as any).Matomo, + matomoLoaded: matomoManager?.isMatomoLoaded?.() || false, + initialized: matomoManager?.getState?.()?.initialized || false + }; + }, [], (result: any) => { + browser.assert.ok(result.value.initialized, `Matomo should be initialized after accepting consent (initialized=${result.value.initialized}, loaded=${result.value.matomoLoaded})`); + }); +} + +// Helper 2b: Reject consent via manage preferences +function rejectConsent(browser: NightwatchBrowser) { + return browser + .waitForElementVisible('*[data-id="matomoModalModalDialogModalBody-react"]') + .click('[data-id="matomoModal-modal-footer-cancel-react"]') // Click "Manage Preferences" + .waitForElementVisible('*[data-id="managePreferencesModalModalDialogModalBody-react"]') // Wait for preferences dialog + .waitForElementVisible('*[data-id="matomoPerfAnalyticsToggleSwitch"]') + .saveScreenshot('./reports/screenshots/matomo-preferences-before-toggle.png') // Debug screenshot + .execute(function() { + // Force click using JavaScript to bypass modal overlay issues + const element = document.querySelector('[data-id="matomoPerfAnalyticsToggleSwitch"]') as HTMLElement; + if (element) { + element.click(); + return { success: true }; + } + return { success: false, error: 'Toggle element not found' }; + }, [], (result: any) => { + if (!result.value || !result.value.success) { + throw new Error(`Failed to click performance analytics toggle: ${result.value?.error || 'Unknown error'}`); + } + }) + .waitForElementVisible('*[data-id="managePreferencesModal-modal-footer-ok-react"]') + .saveScreenshot('./reports/screenshots/matomo-preferences-before-ok.png') // Debug screenshot before OK click + .execute(function() { + // Force click OK button using JavaScript to bypass overlay issues + const okButton = document.querySelector('[data-id="managePreferencesModal-modal-footer-ok-react"]') as HTMLElement; + if (okButton) { + okButton.click(); + return { success: true }; + } + return { success: false, error: 'OK button not found' }; + }, [], (result: any) => { + if (!result.value || !result.value.success) { + throw new Error(`Failed to click OK button: ${result.value?.error || 'Unknown error'}`); + } + }) + .waitForElementNotVisible('*[data-id="managePreferencesModalModalDialogModalBody-react"]') + // Wait for bot detection and Matomo initialization + .waitForElementPresent({ + selector: `//*[@data-id='matomo-bot-detection-complete']`, + locateStrategy: 'xpath', + timeout: 5000 + }) + .waitForElementPresent({ + selector: `//*[@data-id='matomo-initialized']`, + locateStrategy: 'xpath', + timeout: 5000 + }) + .execute(function() { + // Verify Matomo initialization + const matomoManager = (window as any)._matomoManagerInstance; + return { + hasPaq: !!(window as any)._paq, + hasMatomo: !!(window as any).Matomo, + matomoLoaded: matomoManager?.isMatomoLoaded?.() || false, + initialized: matomoManager?.getState?.()?.initialized || false + }; + }, [], (result: any) => { + browser.assert.ok(result.value.initialized, `Matomo should be initialized (initialized=${result.value.initialized}, loaded=${result.value.matomoLoaded})`); + }); +} + +// Helper 3: Check cookie and consent state +function checkConsentState(browser: NightwatchBrowser, expectedHasCookies: boolean, description: string) { + return browser + .execute(function () { + const cookies = document.cookie.split(';').filter(c => c.includes('_pk_')); + const allCookies = document.cookie.split(';'); + const matomoManager = (window as any)._matomoManagerInstance; + const hasConsent = matomoManager.getState().consentGiven; + const isInitialized = matomoManager.getState().initialized; + const botDetection = matomoManager.getBotDetectionResult(); + return { + cookieCount: cookies.length, + hasConsent, + isInitialized, + isBot: botDetection?.isBot, + botType: botDetection?.botType, + allCookiesCount: allCookies.length, + firstCookie: allCookies[0] + }; + }, [], (result: any) => { + const hasCookies = result.value.cookieCount > 0; + browser + .assert.equal(result.value.isInitialized, true, 'Matomo should be initialized before checking cookies') + .assert.ok(true, `🤖 Bot status: isBot=${result.value.isBot}, botType=${result.value.botType}`) + .assert.ok(true, `đŸĒ All cookies: ${result.value.allCookiesCount} total, ${result.value.cookieCount} Matomo cookies`) + .assert.equal(hasCookies, expectedHasCookies, expectedHasCookies ? 'Should have cookies' : 'Should not have cookies') + .assert.equal(result.value.hasConsent, expectedHasCookies, expectedHasCookies ? 'Should have consent' : 'Should not have consent') + .assert.ok(true, `✅ ${description}: ${result.value.cookieCount} cookies, consent=${result.value.hasConsent}, initialized=${result.value.isInitialized}`); + }); +} + +// Helper 3b: Check cookie and consent state (with separate consent expectation) +function checkTrackingState(browser: NightwatchBrowser, expectedHasCookies: boolean, expectedHasConsent: boolean, description: string) { + return browser + .execute(function () { + const cookies = document.cookie.split(';').filter(c => c.includes('_pk_')); + const matomoManager = (window as any)._matomoManagerInstance; + const hasConsent = matomoManager.getState().consentGiven; + const currentMode = matomoManager.getState().currentMode; + return { cookieCount: cookies.length, hasConsent, currentMode }; + }, [], (result: any) => { + const hasCookies = result.value.cookieCount > 0; + browser + .assert.equal(hasCookies, expectedHasCookies, expectedHasCookies ? 'Should have cookies' : 'Should not have cookies') + .assert.equal(result.value.hasConsent, expectedHasConsent, expectedHasConsent ? 'Should have consent' : 'Should not have consent') + .assert.ok(true, `✅ ${description}: ${result.value.cookieCount} cookies, consent=${result.value.hasConsent}, mode=${result.value.currentMode}`); + }); +} + +// Helper: Just initialize debug plugin (no state checking, no clearing) +function initDebugPlugin(browser: NightwatchBrowser) { + return browser + .execute(function () { + const matomoManager = (window as any)._matomoManagerInstance; + if (!matomoManager) return { success: false, error: 'No MatomoManager' }; + + return new Promise((resolve) => { + matomoManager.loadDebugPluginForE2E().then((debugHelpers: any) => { + // Don't clear data - we want to keep tracking events across reload + (window as any).__matomoDebugHelpers = debugHelpers; + resolve({ success: true }); + }).catch((error: any) => { + resolve({ success: false, error: error.message }); + }); + }); + }, [], (result: any) => { + browser.assert.ok(result.value.success, 'Debug plugin reconnected'); + }); +} + +// Helper 4: Reload and check persistence (with debug plugin ready) +function reloadAndCheckPersistence(browser: NightwatchBrowser, expectedHasModal: boolean, expectedHasCookies: boolean) { + return browser + .refreshPage() + .waitForElementPresent({ + selector: `//*[@data-id='compilerloaded']`, + locateStrategy: 'xpath', + timeout: 120000 + }) + .pause(2000) + .perform(() => initDebugPlugin(browser)) // Initialize debug plugin after reload + .execute(function () { + const hasModal = !!document.querySelector('[data-id="matomoModalModalDialogModalBody-react"]'); + const cookies = document.cookie.split(';').filter(c => c.includes('_pk_')); + return { hasModal, cookieCount: cookies.length }; + }, [], (result: any) => { + browser + .assert.equal(result.value.hasModal, expectedHasModal, expectedHasModal ? 'Should have modal after reload' : 'No modal after reload') + .assert.equal(result.value.cookieCount > 0, expectedHasCookies, expectedHasCookies ? 'Cookies should persist' : 'No cookies should persist') + .assert.ok(true, `✅ Reload check: modal=${result.value.hasModal}, ${result.value.cookieCount} cookies`); + }); +} + +// Helper 5: Trigger tracking event by clicking element +function triggerEvent(browser: NightwatchBrowser, elementId: string, description: string = '') { + const displayName = description || elementId.replace('verticalIcons', '').replace('Icon', ''); + return browser + .waitForElementVisible(`[data-id="${elementId}"]`, 5000) + .assert.ok(true, `🔍 Element [data-id="${elementId}"] is visible`) + .click(`[data-id="${elementId}"]`) + .assert.ok(true, `đŸ–ąī¸ Clicked: ${displayName}`) + .pause(2000) // Wait longer for event to be captured by debug plugin + .assert.ok(true, `âąī¸ Waited 2s after ${displayName} click`); +} + +// Helper 6: Check last event has correct tracking mode and visitor ID +function checkLastEventMode(browser: NightwatchBrowser, expectedMode: 'cookie' | 'anon', expectedCategory: string, expectedAction: string, expectedName: string, description: string) { + return browser + .pause(3000) // Extra wait to ensure debug plugin captured the event (increased from 1000ms) + .execute(function () { + const debugHelpers = (window as any).__matomoDebugHelpers; + if (!debugHelpers) return { error: 'Debug helpers not found' }; + + const events = debugHelpers.getEvents(); + if (events.length === 0) return { error: 'No events found' }; + + // Filter out bot-detection and landingPage (consent modal) events to find last user navigation event + const userEvents = events.filter(e => { + const category = e.e_c || e.category || ''; + return category !== 'bot-detection' && category !== 'landingPage'; + }); + + if (userEvents.length === 0) return { error: 'No user navigation events found (only bot-detection/landingPage events)' }; + + const lastEvent = userEvents[userEvents.length - 1]; + + // Store ALL events as JSON string in browser global for Nightwatch visibility + (window as any).__detectedevents = JSON.stringify(events, null, 2); + + // Debug: Show ALL events with index, category, and timestamp + const allEventsSummary = events.map((e, idx) => ({ + idx, + cat: e.e_c || e.category || 'unknown', + act: e.e_a || e.action || 'unknown', + name: e.e_n || e.name || 'unknown', + ts: e.timestamp || e._cacheId || 'no-ts' + })); + + // Debug: Show last 3 USER events (after filtering) with categories + const recentEvents = userEvents.slice(-3).map((e, relIdx) => { + const absIdx = events.indexOf(e); + return { + idx: absIdx, + cat: e.e_c || e.category, + act: e.e_a || e.action, + name: e.e_n || e.name + }; + }); + + return { + mode: lastEvent.dimension1, // 'cookie' or 'anon' + hasVisitorId: !!lastEvent.visitorId && lastEvent.visitorId !== 'null', + visitorId: lastEvent.visitorId, + eventName: lastEvent.e_n || lastEvent.name || 'unknown', + category: lastEvent.e_c || lastEvent.category || 'unknown', + action: lastEvent.e_a || lastEvent.action || 'unknown', + totalEvents: events.length, + userEventsCount: userEvents.length, + recentEvents: JSON.stringify(recentEvents), + allEventsSummary: JSON.stringify(allEventsSummary), + allEventsJson: JSON.stringify(events, null, 2), // Include in return for immediate logging + // Domain-specific dimension check + trackingMode: lastEvent.dimension1, // Should be same as mode but checking dimension specifically + clickAction: lastEvent.dimension3, // Should be 'click' for click events, null for non-click + dimensionInfo: `d1=${lastEvent.dimension1}, d3=${lastEvent.dimension3 || 'null'}` + }; + }, [], (result: any) => { + const expectedHasId = expectedMode === 'cookie'; + browser + .assert.ok(true, `📋 All events (${result.value.totalEvents}): ${result.value.allEventsSummary}`) + .assert.ok(true, `📋 Recent user events (last 3): ${result.value.recentEvents}`) + .assert.ok(true, `📊 Total: ${result.value.totalEvents} events, ${result.value.userEventsCount} user events`) + .assert.equal(result.value.mode, expectedMode, `Event should be in ${expectedMode} mode`) + .assert.equal(result.value.hasVisitorId, expectedHasId, expectedHasId ? 'Should have visitor ID' : 'Should NOT have visitor ID') + .assert.equal(result.value.category, expectedCategory, `Event should have category "${expectedCategory}"`) + .assert.equal(result.value.action, expectedAction, `Event should have action "${expectedAction}"`) + .assert.equal(result.value.eventName, expectedName, `Event should have name "${expectedName}"`) + .assert.ok(result.value.trackingMode, 'Custom dimension 1 (trackingMode) should be set') + .assert.ok(true, `đŸŽ¯ Domain dimensions: ${result.value.dimensionInfo} (localhost uses d1=trackingMode, d3=clickAction)`) + .assert.ok(true, `✅ ${description}: ${result.value.category}/${result.value.action}/${result.value.eventName} → ${result.value.mode} mode, visitorId=${result.value.hasVisitorId ? 'yes' : 'no'}`); + + // Store visitor ID globally for comparison later + (browser as any).__lastVisitorId = result.value.visitorId; + }); +} + +// Helper 7: Remember cookie value for later comparison +function rememberCookieValue(browser: NightwatchBrowser, description: string) { + return browser + .execute(function () { + // Find the _pk_id cookie + const cookies = document.cookie.split(';'); + const pkCookie = cookies.find(c => c.trim().startsWith('_pk_id')); + return { pkCookie: pkCookie ? pkCookie.trim() : null }; + }, [], (result: any) => { + (browser as any).__savedCookie = result.value.pkCookie; + browser.assert.ok(true, `📝 ${description}: Saved cookie ${result.value.pkCookie ? result.value.pkCookie.substring(0, 20) + '...' : 'none'}`); + }); +} + +// Helper 8: Check cookie value is exactly the same as before +function checkSameCookie(browser: NightwatchBrowser, description: string) { + return browser + .execute(function () { + // Find the _pk_id cookie again + const cookies = document.cookie.split(';'); + const pkCookie = cookies.find(c => c.trim().startsWith('_pk_id')); + return { pkCookie: pkCookie ? pkCookie.trim() : null }; + }, [], (result: any) => { + const savedCookie = (browser as any).__savedCookie; + if (savedCookie && result.value.pkCookie) { + browser + .assert.equal(result.value.pkCookie, savedCookie, 'Cookie value should be exactly the same after reload') + .assert.ok(true, `✅ ${description}: Same cookie persisted`); + } else { + browser.assert.ok(true, `â„šī¸ ${description}: No cookies to compare`); + } + }); +} + +// Helper 8b: Check cookie value is different from before (new visitor ID) +function checkNewCookie(browser: NightwatchBrowser, description: string) { + return browser + .execute(function () { + // Find the _pk_id cookie again + const cookies = document.cookie.split(';'); + const pkCookie = cookies.find(c => c.trim().startsWith('_pk_id')); + return { pkCookie: pkCookie ? pkCookie.trim() : null }; + }, [], (result: any) => { + const savedCookie = (browser as any).__savedCookie; + if (savedCookie && result.value.pkCookie) { + browser + .assert.notEqual(result.value.pkCookie, savedCookie, 'Cookie value should be different (new visitor ID)') + .assert.ok(true, `✅ ${description}: New visitor ID generated`); + } else if (result.value.pkCookie && !savedCookie) { + browser.assert.ok(true, `✅ ${description}: New visitor ID created (no previous cookie)`); + } else { + browser.assert.ok(true, `â„šī¸ ${description}: No cookies found`); + } + }); +} + +// Helper 9: Dump all debug events to Nightwatch log +function dumpAllEvents(browser: NightwatchBrowser, description: string) { + return browser + .execute(function () { + const debugHelpers = (window as any).__matomoDebugHelpers; + if (!debugHelpers) return { error: 'No debug helpers found' }; + + const events = debugHelpers.getEvents(); + return { + totalEvents: events.length, + allEventsJson: JSON.stringify(events, null, 2), + events: events.map((e: any, i: number) => ({ + index: i, + mode: e.dimension1, + visitorId: e.visitorId, + eventName: e.e_n || 'unknown', + category: e.e_c || 'unknown', + action: e.e_a || 'unknown' + })) + }; + }, [], (result: any) => { + browser.assert.ok(true, `📊 ${description}: ${result.value.totalEvents} total events`); + browser.assert.ok(true, `📋 Full JSON: ${result.value.allEventsJson}`); + if (result.value.events && result.value.events.length > 0) { + result.value.events.forEach((event: any) => { + browser.assert.ok(true, ` Event ${event.index}: ${event.eventName} → mode=${event.mode}, visitorId=${event.visitorId ? 'yes' : 'no'}, ${event.category}/${event.action}`); + }); + } else { + browser.assert.ok(true, ' No events found in debug plugin'); + } + }); +} + +// Helper 10: Show stored events from browser global +function showStoredEvents(browser: NightwatchBrowser, description: string) { + const storedEvents = (browser as any).__detectedevents; + if (storedEvents) { + browser.assert.ok(true, `📋 ${description} - Stored events: ${storedEvents}`); + } else { + browser.assert.ok(true, `📋 ${description} - No events stored yet`); + } + return browser; +} + +// Helper: Check if element exists and is clickable +function checkElementExists(browser: NightwatchBrowser, elementId: string, description: string) { + return browser + .execute(function (elementId) { + const element = document.querySelector(`[data-id="${elementId}"]`); + return { + exists: !!element, + visible: element ? window.getComputedStyle(element).display !== 'none' : false, + clickable: element ? !element.hasAttribute('disabled') : false, + tagName: element ? element.tagName : null, + className: element ? element.className : null + }; + }, [elementId], (result: any) => { + browser.assert.ok(result.value.exists, `${description}: Element [data-id="${elementId}"] should exist`); + if (result.value.exists) { + browser.assert.ok(true, `✅ ${description}: Found ${result.value.tagName}.${result.value.className}, visible=${result.value.visible}, clickable=${result.value.clickable}`); + } + }); +} + +// Predefined common events +function clickHome(browser: NightwatchBrowser) { + return browser + .perform(() => checkElementExists(browser, 'verticalIconsHomeIcon', 'Home button check')) + .perform(() => triggerEvent(browser, 'verticalIconsHomeIcon', 'Home')); +} + +function clickSolidity(browser: NightwatchBrowser) { + return triggerEvent(browser, 'verticalIconsKindsolidity', 'Solidity Compiler'); +} + +function clickFileExplorer(browser: NightwatchBrowser) { + return triggerEvent(browser, 'verticalIconsKindfilePanel', 'File Explorer'); +} + +// Helper: Navigate to settings and switch matomo preferences +function switchMatomoSettings(browser: NightwatchBrowser, enablePerformance: boolean, description: string) { + return browser + .waitForElementVisible('*[data-id="topbar-settingsIcon"]') + .click('*[data-id="topbar-settingsIcon"]') // Open settings + .waitForElementVisible('*[data-id="settings-sidebar-analytics"]') + .click('*[data-id="settings-sidebar-analytics"]') // Click Analytics section + .waitForElementVisible('*[data-id="matomo-perf-analyticsSwitch"]') + .execute(function () { + // Check current toggle state + const perfToggle = document.querySelector('[data-id="matomo-perf-analyticsSwitch"]'); + const isCurrentlyOn = perfToggle?.querySelector('.fa-toggle-on'); + return { currentState: !!isCurrentlyOn }; + }, [], (result: any) => { + const currentState = result.value.currentState; + const needsClick = currentState !== enablePerformance; + + if (needsClick) { + browser + .click('*[data-id="matomo-perf-analyticsSwitch"]') // Toggle performance analytics + .pause(1000) // Wait for setting to apply + .assert.ok(true, `🔧 ${description}: Switched performance analytics to ${enablePerformance ? 'enabled' : 'disabled'}`); + } else { + browser.assert.ok(true, `🔧 ${description}: Performance analytics already ${enablePerformance ? 'enabled' : 'disabled'}`); + } + }) + .pause(2000); // Wait for changes to take effect +} + +// Helper: Verify settings state matches expectations +function verifySettingsState(browser: NightwatchBrowser, expectedPerformanceEnabled: boolean, description: string) { + return browser + .execute(function () { + const config = JSON.parse(window.localStorage.getItem('config-v0.8:.remix.config') || '{}'); + const perfAnalytics = config['settings/matomo-perf-analytics']; + return { perfAnalytics }; + }, [], (result: any) => { + browser + .assert.equal(result.value.perfAnalytics, expectedPerformanceEnabled, `Performance analytics should be ${expectedPerformanceEnabled ? 'enabled' : 'disabled'} in localStorage`) + .assert.ok(true, `✅ ${description}: localStorage shows performance=${result.value.perfAnalytics}`); + }); +} + +// Simple helper: setup and check tracking state +function setupAndCheckState(browser: NightwatchBrowser, description: string) { + return browser + .execute(function () { + // Setup debug plugin + const matomoManager = (window as any)._matomoManagerInstance; + if (!matomoManager) return { success: false, error: 'No MatomoManager' }; + + return new Promise((resolve) => { + matomoManager.loadDebugPluginForE2E().then((debugHelpers: any) => { + debugHelpers.clearData(); + (window as any).__matomoDebugHelpers = debugHelpers; + resolve({ success: true }); + }).catch((error: any) => { + resolve({ success: false, error: error.message }); + }); + }); + }, [], (result: any) => { + browser.assert.ok(result.value.success, 'Debug plugin setup'); + }) + .execute(function () { + // Check current state + const debugHelpers = (window as any).__matomoDebugHelpers; + const matomoManager = (window as any)._matomoManagerInstance; + + if (!debugHelpers || !matomoManager) { + return { error: 'Missing components' }; + } + + const events = debugHelpers.getEvents(); + const state = matomoManager.getState(); + const queueStatus = matomoManager.getQueueStatus(); + + // Check cookies + const cookies = document.cookie.split(';').reduce((acc, cookie) => { + const [name, value] = cookie.trim().split('='); + if (name) acc[name] = value; + return acc; + }, {} as any); + const matomoCookies = Object.keys(cookies).filter(name => name.startsWith('_pk_')); + + // Events by type + const cookieEvents = events.filter((e: any) => e.dimension1 === 'cookie'); + const anonymousEvents = events.filter((e: any) => e.dimension1 === 'anon'); + const eventsWithId = events.filter((e: any) => e.visitorId && e.visitorId !== 'null'); + + return { + totalEvents: events.length, + cookieEvents: cookieEvents.length, + anonymousEvents: anonymousEvents.length, + eventsWithId: eventsWithId.length, + queuedEvents: queueStatus.queueLength, + hasCookies: matomoCookies.length > 0, + cookieCount: matomoCookies.length, + hasConsent: state.consentGiven, + mode: state.currentMode, + summary: `${events.length}events(${cookieEvents.length}cookie/${anonymousEvents.length}anon), ${queueStatus.queueLength}queue, ${matomoCookies.length}cookies, consent=${state.consentGiven}` + }; + }, [], (result: any) => { + browser.assert.ok(true, `${description}: ${result.value.summary}`); + return result.value; + }); +} + +// Helper: Verify event tracking with dimension 3 check +function verifyEventTracking(browser: NightwatchBrowser, expectedCategory: string, expectedAction: string, expectedName: string, isClickEvent: boolean, description: string) { + return browser + .pause(1000) // Wait for event to be captured + .execute(function () { + const debugHelpers = (window as any).__matomoDebugHelpers; + if (!debugHelpers) return { error: 'Debug helpers not found' }; + + const events = debugHelpers.getEvents(); + if (events.length === 0) return { error: 'No events found' }; + + // Filter out bot-detection and landingPage (consent modal) events to find last user navigation event + const userEvents = events.filter(e => { + const category = e.e_c || e.category || ''; + return category !== 'bot-detection' && category !== 'landingPage'; + }); + + if (userEvents.length === 0) return { error: 'No user navigation events found (only bot-detection/landingPage events)' }; + + const lastEvent = userEvents[userEvents.length - 1]; + return { + category: lastEvent.e_c || lastEvent.category || 'unknown', + action: lastEvent.e_a || lastEvent.action || 'unknown', + name: lastEvent.e_n || lastEvent.name || 'unknown', + mode: lastEvent.dimension1, + isClick: lastEvent.dimension3 === true || lastEvent.dimension3 === 'true', // Our click dimension (handle string/boolean) + hasVisitorId: !!lastEvent.visitorId && lastEvent.visitorId !== 'null' + }; + }, [], (result: any) => { + browser + .assert.equal(result.value.category, expectedCategory, `Event category should be "${expectedCategory}"`) + .assert.equal(result.value.action, expectedAction, `Event action should be "${expectedAction}"`) + .assert.equal(result.value.name, expectedName, `Event name should be "${expectedName}"`) + .assert.equal(result.value.mode, 'cookie', 'Event should be in cookie mode') + .assert.equal(result.value.isClick, isClickEvent, `Dimension 3 (isClick) should be ${isClickEvent}`) + .assert.equal(result.value.hasVisitorId, true, 'Should have visitor ID in cookie mode') + .assert.ok(true, `✅ ${description}: ${result.value.category}/${result.value.action}/${result.value.name}, isClick=${result.value.isClick}, mode=${result.value.mode}`); + }); +} + +// Helper: Check prequeue vs debug events (before/after consent) +function checkPrequeueState(browser: NightwatchBrowser, description: string) { + return browser + .execute(function () { + const matomoManager = (window as any)._matomoManagerInstance; + const debugHelpers = (window as any).__matomoDebugHelpers; + + if (!matomoManager || !debugHelpers) { + return { error: 'Missing components' }; + } + + const queueStatus = matomoManager.getQueueStatus(); + const events = debugHelpers.getEvents(); + + return { + queueLength: queueStatus.queueLength, + debugEvents: events.length, + hasConsent: matomoManager.getState().consentGiven, + summary: `Queue: ${queueStatus.queueLength} events, Debug: ${events.length} events, Consent: ${matomoManager.getState().consentGiven}` + }; + }, [], (result: any) => { + browser.assert.ok(true, `📊 ${description}: ${result.value.summary}`); + return result.value; + }); +} + +// Helper: Verify prequeue has events but debug is empty +function verifyPrequeueActive(browser: NightwatchBrowser, description: string) { + return browser + .execute(function () { + const matomoManager = (window as any)._matomoManagerInstance; + const debugHelpers = (window as any).__matomoDebugHelpers; + const queueStatus = matomoManager.getQueueStatus(); + const events = debugHelpers.getEvents(); + return { queueLength: queueStatus.queueLength, debugEvents: events.length }; + }, [], (result: any) => { + browser + .assert.ok(result.value.queueLength > 0, `Should have queued events (found ${result.value.queueLength})`) + .assert.equal(result.value.debugEvents, 0, 'Should have no debug events before consent') + .assert.ok(true, `✅ ${description}: ${result.value.queueLength} queued, ${result.value.debugEvents} debug`); + }); +} + +// Helper: Verify queue flushed to debug with correct mode +function verifyQueueFlushed(browser: NightwatchBrowser, expectedMode: 'cookie' | 'anon', description: string) { + return browser + .execute(function (expectedMode) { + const matomoManager = (window as any)._matomoManagerInstance; + const debugHelpers = (window as any).__matomoDebugHelpers; + const queueStatus = matomoManager.getQueueStatus(); + const events = debugHelpers.getEvents(); + const modeEvents = events.filter((e: any) => e.dimension1 === expectedMode); + return { + queueLength: queueStatus.queueLength, + debugEvents: events.length, + modeEvents: modeEvents.length, + firstEvent: events[0] || null + }; + }, [expectedMode], (result: any) => { + browser + .assert.equal(result.value.queueLength, 0, 'Queue should be empty after consent') + .assert.ok(result.value.debugEvents > 0, `Should have debug events after flush (found ${result.value.debugEvents})`) + .assert.ok(result.value.modeEvents > 0, `Should have ${expectedMode} mode events (found ${result.value.modeEvents})`) + .assert.ok(true, `✅ ${description}: ${result.value.queueLength} queued, ${result.value.debugEvents} debug (${result.value.modeEvents} ${expectedMode} mode)`); + + // Verify first event mode and visitor ID + if (result.value.firstEvent) { + const expectedHasId = expectedMode === 'cookie'; + browser + .assert.equal(result.value.firstEvent.dimension1, expectedMode, `Flushed events should be in ${expectedMode} mode`) + .assert.equal(!!result.value.firstEvent.visitorId, expectedHasId, expectedHasId ? 'Flushed events should have visitor ID' : 'Flushed events should NOT have visitor ID'); + } + }); +} + +module.exports = { + '@disabled': false, + before: function (browser: NightwatchBrowser, done: () => void) { + init(browser, done, 'http://127.0.0.1:8080', false) + }, + + /** + * Simple pattern: User accepts cookies → has cookies + visitor ID → reload → same state + */ + 'User accepts cookies #pr #group1': function (browser: NightwatchBrowser) { + browser + .perform(() => startFreshTest(browser)) + .perform(() => setupAndCheckState(browser, 'Initial state')) + .perform(() => acceptConsent(browser)) + .perform(() => checkConsentState(browser, true, 'After accept')) + .perform(() => clickHome(browser)) // Trigger tracking event + .perform(() => checkLastEventMode(browser, 'cookie', 'topbar', 'header', 'Home', 'Home click event')) // Verify event was tracked with cookie mode + visitor ID + .perform(() => rememberCookieValue(browser, 'Before reload')) // Remember the cookie value + .perform(() => reloadAndCheckPersistence(browser, false, true)) + .perform(() => checkSameCookie(browser, 'After reload')) // Check cookie is exactly the same + .perform(() => clickHome(browser)) // Click again after reload - same visitor ID guaranteed by cookie + .perform(() => checkLastEventMode(browser, 'cookie', 'topbar', 'header', 'Home', 'Home click after reload')) // Verify event after reload also tracked correctly + .assert.ok(true, '✅ Pattern complete: accept → cookies → reload → same cookies → same visitor ID in new events') + }, + + /** + * Simple pattern: User rejects cookies → no cookies + no visitor ID → reload → same anonymous state + */ + 'User rejects cookies (anonymous mode) #pr #group2': function (browser: NightwatchBrowser) { + browser + .perform(() => startFreshTest(browser)) + .perform(() => setupAndCheckState(browser, 'Initial state')) + .perform(() => rejectConsent(browser)) + .perform(() => checkConsentState(browser, false, 'After reject')) + .perform(() => clickHome(browser)) // Trigger tracking event + .perform(() => checkLastEventMode(browser, 'anon', 'topbar', 'header', 'Home', 'Home click event (anonymous)')) // Verify event was tracked in anonymous mode with no visitor ID + .perform(() => reloadAndCheckPersistence(browser, false, false)) + .perform(() => clickHome(browser)) // Click again after reload - still anonymous + .perform(() => checkLastEventMode(browser, 'anon', 'topbar', 'header', 'Home', 'Home click after reload (anonymous)')) // Verify event after reload still anonymous + .assert.ok(true, '✅ Pattern complete: reject → anonymous → reload → same anonymous state → no visitor ID persistence') + }, + + /** + * Settings tab pattern: User switches preferences via Settings → Analytics + */ + 'User switches settings via Settings tab #pr #group3': function (browser: NightwatchBrowser) { + browser + .perform(() => startFreshTest(browser)) + .perform(() => setupAndCheckState(browser, 'Initial state')) + .perform(() => acceptConsent(browser)) // Start with cookie mode + .perform(() => checkConsentState(browser, true, 'After accept')) + .perform(() => clickHome(browser)) // Trigger event in cookie mode + .perform(() => checkLastEventMode(browser, 'cookie', 'topbar', 'header', 'Home', 'Initial cookie mode event')) + .perform(() => rememberCookieValue(browser, 'Original cookie mode')) // Remember the first cookie + + // Switch to anonymous via settings + .perform(() => switchMatomoSettings(browser, false, 'Disable performance analytics')) + .perform(() => verifySettingsState(browser, false, 'Settings verification')) + .perform(() => checkConsentState(browser, false, 'After settings switch to anonymous')) + .perform(() => clickHome(browser)) // Trigger event in anonymous mode + .perform(() => checkLastEventMode(browser, 'anon', 'topbar', 'header', 'Home', 'After switch to anonymous')) + + // Switch back to cookie mode via settings + .perform(() => switchMatomoSettings(browser, true, 'Enable performance analytics')) + .perform(() => verifySettingsState(browser, true, 'Settings verification')) + .perform(() => checkConsentState(browser, true, 'After settings switch to cookie')) + .perform(() => clickHome(browser)) // Trigger event in cookie mode again + .perform(() => checkLastEventMode(browser, 'cookie', 'topbar', 'header', 'Home', 'After switch back to cookie')) + .perform(() => checkNewCookie(browser, 'New visitor ID after anonymous switch')) // Verify it's a NEW cookie, not the old one + .assert.ok(true, '✅ Pattern complete: settings toggle → anonymous ↔ cookie mode switching works with new visitor ID') + }, + + /** + * Simple pattern: Prequeue → Accept → Queue flush to cookie mode + */ + 'Prequeue flush to cookie mode #pr #group4': function (browser: NightwatchBrowser) { + browser + .perform(() => startFreshTest(browser)) + .perform(() => setupAndCheckState(browser, 'Initial state')) + .pause(3000) // Wait for events to accumulate in prequeue + .perform(() => checkPrequeueState(browser, 'Before consent')) + .perform(() => verifyPrequeueActive(browser, 'Prequeue working')) + .perform(() => acceptConsent(browser)) // Accept consent + .pause(2000) // Wait for queue flush + .perform(() => checkPrequeueState(browser, 'After consent')) + .perform(() => verifyQueueFlushed(browser, 'cookie', 'Queue flush successful')) + .assert.ok(true, '✅ Pattern complete: prequeue → accept → queue flush to cookie mode') + }, + + /** + * Simple pattern: Prequeue → Reject → Queue flush to anonymous mode + */ + 'Prequeue flush to anonymous mode #pr #group5': function (browser: NightwatchBrowser) { + browser + .perform(() => startFreshTest(browser)) + .perform(() => setupAndCheckState(browser, 'Initial state')) + .pause(3000) // Wait for events to accumulate in prequeue + .perform(() => checkPrequeueState(browser, 'Before consent')) + .perform(() => verifyPrequeueActive(browser, 'Prequeue working')) + .perform(() => rejectConsent(browser)) // Reject consent + .pause(2000) // Wait for queue flush + .perform(() => checkPrequeueState(browser, 'After consent')) + .perform(() => verifyQueueFlushed(browser, 'anon', 'Queue flush successful')) + .assert.ok(true, '✅ Pattern complete: prequeue → reject → queue flush to anonymous mode') + }, + + /** + * Simple pattern: Test both tracking methods work with dimension 3 + */ + 'Event tracking verification (plugin + context) #pr #group6': function (browser: NightwatchBrowser) { + browser + .perform(() => startFreshTest(browser)) + .perform(() => setupAndCheckState(browser, 'Initial state')) + .perform(() => acceptConsent(browser)) // Accept to test cookie mode + .perform(() => checkConsentState(browser, true, 'After accept')) + + // Test git init tracking (Git init - should be click event) + .waitForElementVisible('*[data-id="verticalIconsKinddgit"]') + .click('*[data-id="verticalIconsKinddgit"]') // Open dgit plugin + .pause(1000) + .waitForElementVisible('*[data-id="initgit-btn"]') + .click('*[data-id="initgit-btn"]') // Initialize git repo + .pause(1000) + .perform(() => verifyEventTracking(browser, 'git', 'INIT', 'unknown', true, 'Git init click event')) + + // Test context-based tracking (Settings - should be click event) + .waitForElementVisible('*[data-id="topbar-settingsIcon"]') + .click('*[data-id="topbar-settingsIcon"]') + .pause(1000) + .perform(() => verifyEventTracking(browser, 'topbar', 'header', 'Settings', true, 'Context-based click event')) + + .assert.ok(true, '✅ Both plugin and context tracking work with correct dimension 3') + }, + + /** + * Test consent expiration (6 months) - should re-prompt user who previously declined + * + * This tests the end-to-end UI behavior: + * 1. User declines analytics (rejectConsent) + * 2. Simulate 7 months passing (expired timestamp) + * 3. Refresh page to trigger expiration check + * 4. Verify consent dialog appears again + */ + 'Consent expiration after 6 months #pr #group7': function (browser: NightwatchBrowser) { + browser + .perform(() => startFreshTest(browser)) + + // First, simulate user declining analytics in the past by using the settings UI + .perform(() => rejectConsent(browser)) // This sets matomo-perf-analytics to false + .pause(1000) + + // Now manipulate the consent timestamp to simulate expiration + .execute(function () { + // Calculate 7 months ago timestamp (expired) + const sevenMonthsAgo = new Date(); + sevenMonthsAgo.setMonth(sevenMonthsAgo.getMonth() - 7); + const expiredTimestamp = sevenMonthsAgo.getTime().toString(); + + // Override the consent timestamp to be expired + localStorage.setItem('matomo-analytics-consent', expiredTimestamp); + + return { + timestamp: expiredTimestamp, + timestampDate: new Date(parseInt(expiredTimestamp)).toISOString() + }; + }, [], (result: any) => { + browser.assert.ok(true, `Set expired consent timestamp: ${result.value.timestampDate}`); + }) + + // Reload the page to trigger consent expiration check + .refresh() + .pause(2000) + + // Check if consent dialog appears due to expiration + .waitForElementVisible('body', 5000) + .pause(3000) // Give time for dialog to appear + + // Check if modal is visible (consent should re-appear due to expiration) + .execute(function () { + // Check for modal elements that indicate consent dialog + const modalElement = document.querySelector('#modal-dialog, .modal, [data-id="matomoModal"], [role="dialog"]'); + const modalBackdrop = document.querySelector('.modal-backdrop, .modal-overlay'); + + // Also check for consent-related text that might indicate the dialog + const bodyText = document.body.textContent || ''; + const hasConsentText = bodyText.includes('Analytics') || + bodyText.includes('cookies') || + bodyText.includes('privacy') || + bodyText.includes('Accept') || + bodyText.includes('Manage'); + + // Check if Matomo manager shows consent is needed + const matomoManager = (window as any).__matomoManager; + let shouldShow = false; + if (matomoManager && typeof matomoManager.shouldShowConsentDialog === 'function') { + try { + shouldShow = matomoManager.shouldShowConsentDialog(); + } catch (e) { + // Ignore errors, fallback to other checks + } + } + + return { + modalVisible: !!modalElement, + modalBackdrop: !!modalBackdrop, + hasConsentText: hasConsentText, + shouldShowConsent: shouldShow + }; + }, [], (result: any) => { + const consentAppeared = result.value.modalVisible || result.value.hasConsentText || result.value.shouldShowConsent; + browser.assert.ok(consentAppeared, + `Consent dialog should re-appear after expiration for users who previously declined. Modal: ${result.value.modalVisible}, Text: ${result.value.hasConsentText}, Should show: ${result.value.shouldShowConsent}` + ); + }) + + .assert.ok(true, '✅ Consent expiration test complete - dialog re-appears after 6 months for users who previously declined') + }, + + /** + * Test timestamp boundary: exactly 6 months vs over 6 months + * + * This tests the core expiration logic mathematically: + * 1. User declines analytics (to set up proper state) + * 2. Test 5 months ago timestamp → should NOT be expired + * 3. Test 7 months ago timestamp → should BE expired + * 4. Validate the boundary calculation works correctly + */ + 'Consent timestamp boundary test #pr #group8': function (browser: NightwatchBrowser) { + browser + .perform(() => startFreshTest(browser)) + .perform(() => rejectConsent(browser)) // User declines analytics + .pause(2000) + + // Test various timestamps and check if they would trigger expiration + .execute(function () { + // Test different timestamps + const now = new Date(); + + // 5 months ago - should NOT be expired + const fiveMonths = new Date(); + fiveMonths.setMonth(fiveMonths.getMonth() - 5); + + // 7 months ago - should BE expired + const sevenMonths = new Date(); + sevenMonths.setMonth(sevenMonths.getMonth() - 7); + + // Test the expiration logic directly + const testExpiration = (timestamp: string) => { + const consentDate = new Date(Number(timestamp)); + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + return consentDate < sixMonthsAgo; + }; + + return { + fiveMonthsExpired: testExpiration(fiveMonths.getTime().toString()), // Should be false + sevenMonthsExpired: testExpiration(sevenMonths.getTime().toString()), // Should be true + fiveMonthsDate: fiveMonths.toISOString(), + sevenMonthsDate: sevenMonths.toISOString() + }; + }, [], (result: any) => { + browser + .assert.equal(result.value.fiveMonthsExpired, false, '5 months should NOT be expired') + .assert.equal(result.value.sevenMonthsExpired, true, '7 months should BE expired') + .assert.ok(true, `Boundary test: 5mo(${result.value.fiveMonthsDate})=${result.value.fiveMonthsExpired}, 7mo(${result.value.sevenMonthsDate})=${result.value.sevenMonthsExpired}`); + }) + + .assert.ok(true, '✅ 6-month boundary logic works correctly') + } +} \ No newline at end of file diff --git a/apps/remix-ide-e2e/src/tests/matomo.test.ts b/apps/remix-ide-e2e/src/tests/matomo.test.ts deleted file mode 100644 index bd29499f706..00000000000 --- a/apps/remix-ide-e2e/src/tests/matomo.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -'use strict' -import { NightwatchBrowser } from 'nightwatch' -import init from '../helpers/init' - -import examples from '../examples/example-contracts' - -const sources = [ - { 'Untitled.sol': { content: examples.ballot.content } } -] - -module.exports = { - '@disabled': true, - before: function (browser: NightwatchBrowser, done: VoidFunction) { - init(browser, done, 'http://127.0.0.1:8080', false) - }, - 'accept all including Matomo anon and perf #group1': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser - .execute(function () { - localStorage.removeItem('config-v0.8:.remix.config') - localStorage.setItem('showMatomo', 'true') - }, []) - .refreshPage() - .perform(done()) - }) - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .execute(function () { - return (window as any)._paq - }, [], (res) => { - console.log('_paq', res) - }) - .waitForElementVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - .pause(1000) - .click('[data-id="matomoModal-modal-footer-ok-react"]') // Accepted - .execute(function () { - return (window as any)._paq - }, [], (res) => { - console.log('_paq', res) - }) - .waitForElementNotVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - .refreshPage() - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .waitForElementNotPresent('*[data-id="matomoModalModalDialogModalBody-react"]') - .waitForElementVisible('*[data-id="topbar-settingsIcon"]') - .click('*[data-id="topbar-settingsIcon"]') - .waitForElementVisible('*[data-id="settings-sidebar-analytics"]') - .click('*[data-id="settings-sidebar-analytics"]') - .waitForElementVisible('*[data-id="matomo-analyticsSwitch"]') - .verify.elementPresent('[data-id="matomo-analyticsSwitch"] .fa-toggle-on') - .verify.elementPresent('[data-id="matomo-perf-analyticsSwitch"] .fa-toggle-on') - .execute(function () { - return JSON.parse(window.localStorage.getItem('config-v0.8:.remix.config'))['settings/matomo-analytics'] == true - }, [], (res) => { - console.log('res', res) - browser.assert.ok((res as any).value, 'matomo analytics is enabled') - }) - .execute(function () { - return JSON.parse(window.localStorage.getItem('config-v0.8:.remix.config'))['settings/matomo-perf-analytics'] == true - }, [], (res) => { - browser.assert.ok((res as any).value, 'matomo perf analytics is enabled') - }) - }, - 'disable matomo perf analytics on manage preferences #group2': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser.execute(function () { - localStorage.removeItem('config-v0.8:.remix.config') - localStorage.setItem('showMatomo', 'true') - localStorage.removeItem('matomo-analytics-consent') - }, []) - .refreshPage() - .perform(done()) - }) - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .waitForElementVisible({ - selector: '*[data-id="matomoModalModalDialogModalBody-react"]', - abortOnFailure: true - }) - .waitForElementVisible('*[data-id="matomoModal-modal-footer-cancel-react"]') - .click('[data-id="matomoModal-modal-footer-cancel-react"]') // click on Manage Preferences - .waitForElementVisible('*[data-id="managePreferencesModalModalDialogModalBody-react"]') - .waitForElementVisible('*[data-id="matomoPerfAnalyticsToggleSwitch"]') - .click('*[data-id="matomoPerfAnalyticsToggleSwitch"]') // disable matomo perf analytics3 - .click('[data-id="managePreferencesModal-modal-footer-ok-react"]') // click on Save Preferences - .pause(2000) - .waitForElementVisible('*[data-id="topbar-settingsIcon"]') - .click('*[data-id="topbar-settingsIcon"]') - .waitForElementVisible('*[data-id="settings-sidebar-analytics"]') - .click('*[data-id="settings-sidebar-analytics"]') - .waitForElementVisible('*[data-id="matomo-perf-analyticsSwitch"]') - .verify.elementPresent('[data-id="matomo-perf-analyticsSwitch"] .fa-toggle-off') - .execute(function () { - return JSON.parse(window.localStorage.getItem('config-v0.8:.remix.config'))['settings/matomo-perf-analytics'] == false - }, [], (res) => { - console.log('res', res) - browser.assert.ok((res as any).value, 'matomo perf analytics is disabled') - }) - }, - 'change settings #group2': function (browser: NightwatchBrowser) { - browser - .click('*[data-id="matomo-perf-analyticsSwitch"]') - .refreshPage() - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .waitForElementNotPresent('*[data-id="matomoModalModalDialogModalBody-react"]') - .waitForElementVisible('*[data-id="topbar-settingsIcon"]') - .click('*[data-id="topbar-settingsIcon"]') - .waitForElementVisible('*[data-id="settings-sidebar-analytics"]') - .click('*[data-id="settings-sidebar-analytics"]') - .waitForElementVisible('*[data-id="matomo-perf-analyticsSwitch"]') - .verify.elementPresent('[data-id="matomo-perf-analyticsSwitch"] .fa-toggle-on') - .click('*[data-id="matomo-perf-analyticsSwitch"]') // disable again - .pause(2000) - .refreshPage() - }, - 'check old timestamp and reappear Matomo #group2': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser.execute(function () { - const oldTimestamp = new Date() - oldTimestamp.setMonth(oldTimestamp.getMonth() - 7) - localStorage.setItem('matomo-analytics-consent', oldTimestamp.getTime().toString()) - }, []) - .refreshPage() - .perform(done()) - }) - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .execute(function () { - - const timestamp = window.localStorage.getItem('matomo-analytics-consent'); - if (timestamp) { - - const consentDate = new Date(Number(timestamp)); - // validate it is actually a date - if (isNaN(consentDate.getTime())) { - return false; - } - // validate it's older than 6 months - const now = new Date(); - const diffInMonths = (now.getFullYear() - consentDate.getFullYear()) * 12 + now.getMonth() - consentDate.getMonth(); - console.log('timestamp', timestamp, consentDate, now.getTime()) - console.log('diffInMonths', diffInMonths) - return diffInMonths > 6; - } - return false; - - }, [], (res) => { - console.log('res', res) - browser.assert.ok((res as any).value, 'matomo performance analytics consent timestamp is set') - }) - .waitForElementVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - .click('[data-id="matomoModal-modal-footer-ok-react"]') // accept - .waitForElementNotVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - }, - 'check recent timestamp and do not reappear Matomo #group3': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser.execute(function () { - const recentTimestamp = new Date() - recentTimestamp.setMonth(recentTimestamp.getMonth() - 1) - localStorage.setItem('matomo-analytics-consent', recentTimestamp.getTime().toString()) - }, []) - .refreshPage() - .perform(done()) - }) - // check if timestamp is younger than 6 months - .execute(function () { - - const timestamp = window.localStorage.getItem('matomo-analytics-consent'); - if (timestamp) { - - const consentDate = new Date(Number(timestamp)); - // validate it is actually a date - if (isNaN(consentDate.getTime())) { - return false; - } - // validate it's younger than 2 months - const now = new Date(); - const diffInMonths = (now.getFullYear() - consentDate.getFullYear()) * 12 + now.getMonth() - consentDate.getMonth(); - console.log('timestamp', timestamp, consentDate, now.getTime()) - console.log('diffInMonths', diffInMonths) - return diffInMonths < 2; - } - return false; - - }, [], (res) => { - console.log('res', res) - browser.assert.ok((res as any).value, 'matomo analytics consent timestamp is set') - }) - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .pause(2000) - .waitForElementNotPresent('*[data-id="matomoModalModalDialogModalBody-react"]') - }, - 'accept Matomo and check timestamp #group3': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser.execute(function () { - localStorage.removeItem('config-v0.8:.remix.config') - localStorage.setItem('showMatomo', 'true') - localStorage.removeItem('matomo-analytics-consent') - }, []) - .refreshPage() - .perform(done()) - }) - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .waitForElementVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - .click('[data-id="matomoModal-modal-footer-ok-react"]') // accept - .waitForElementNotVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - .pause(2000) - .execute(function () { - - const timestamp = window.localStorage.getItem('matomo-analytics-consent'); - if (timestamp) { - - const consentDate = new Date(Number(timestamp)); - // validate it is actually a date - if (isNaN(consentDate.getTime())) { - return false; - } - const now = new Date(); - console.log('timestamp', timestamp, consentDate, now.getTime()) - const diffInMinutes = (now.getTime() - consentDate.getTime()) / (1000 * 60); - console.log('diffInMinutes', diffInMinutes) - return diffInMinutes < 1; - } - return false; - - }, [], (res) => { - console.log('res', res) - browser.assert.ok((res as any).value, 'matomo analytics consent timestamp is to a recent date') - }) - }, - 'check old timestamp and do not reappear Matomo after accept #group3': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser.execute(function () { - const oldTimestamp = new Date() - oldTimestamp.setMonth(oldTimestamp.getMonth() - 7) - localStorage.setItem('matomo-analytics-consent', oldTimestamp.getTime().toString()) - }, []) - .refreshPage() - .perform(done()) - }) - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .pause(2000) - .waitForElementNotPresent('*[data-id="matomoModalModalDialogModalBody-react"]') - }, - 'check recent timestamp and do not reappear Matomo after accept #group3': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser.execute(function () { - const recentTimestamp = new Date() - recentTimestamp.setMonth(recentTimestamp.getMonth() - 1) - localStorage.setItem('matomo-analytics-consent', recentTimestamp.getTime().toString()) - }, []) - .refreshPage() - .perform(done()) - }) - .waitForElementPresent({ - selector: `//*[@data-id='compilerloaded']`, - locateStrategy: 'xpath', - timeout: 120000 - }) - .pause(2000) - .waitForElementNotPresent('*[data-id="matomoModalModalDialogModalBody-react"]') - }, - 'when there is a recent timestamp but no config the dialog should reappear #group3': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser.execute(function () { - localStorage.removeItem('config-v0.8:.remix.config') - const recentTimestamp = new Date() - recentTimestamp.setMonth(recentTimestamp.getMonth() - 1) - localStorage.setItem('matomo-analytics-consent', recentTimestamp.getTime().toString()) - }, []) - .refreshPage() - .perform(done()) - }) - .waitForElementVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - .click('[data-id="matomoModal-modal-footer-ok-react"]') // accept - .waitForElementNotVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - }, - 'when there is a old timestamp but no config the dialog should reappear #group3': function (browser: NightwatchBrowser) { - browser.perform((done) => { - browser.execute(function () { - localStorage.removeItem('config-v0.8:.remix.config') - const oldTimestamp = new Date() - oldTimestamp.setMonth(oldTimestamp.getMonth() - 7) - localStorage.setItem('matomo-analytics-consent', oldTimestamp.getTime().toString()) - }, []) - .refreshPage() - .perform(done()) - }) - .waitForElementVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - .click('[data-id="matomoModal-modal-footer-ok-react"]') // accept - .waitForElementNotVisible('*[data-id="matomoModalModalDialogModalBody-react"]') - }, - 'verify Matomo events are tracked on app start #group4': function (browser: NightwatchBrowser) { - browser - .execute(function () { - return (window as any)._paq - }, [], (res) => { - const expectedEvents = [ - ["trackEvent", "Storage", "activate", "indexedDB"] - ]; - - const actualEvents = (res as any).value; - - const areEventsPresent = expectedEvents.every(expectedEvent => - actualEvents.some(actualEvent => - JSON.stringify(actualEvent) === JSON.stringify(expectedEvent) - ) - ); - - browser.assert.ok(areEventsPresent, 'Matomo events are tracked correctly'); - }) - }, - - '@sources': function () { - return sources - }, - 'Add Ballot #group4': function (browser: NightwatchBrowser) { - browser - .addFile('Untitled.sol', sources[0]['Untitled.sol']) - }, - 'Deploy Ballot #group4': function (browser: NightwatchBrowser) { - browser - .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .clickLaunchIcon('solidity') - .waitForElementVisible('*[data-id="compilerContainerCompileBtn"]') - .click('*[data-id="compilerContainerCompileBtn"]') - .testContracts('Untitled.sol', sources[0]['Untitled.sol'], ['Ballot']) - }, - 'verify Matomo compiler events are tracked #group4': function (browser: NightwatchBrowser) { - browser - .execute(function () { - return (window as any)._paq - }, [], (res) => { - const expectedEvent = ["trackEvent", "compiler", "compiled"]; - const actualEvents = (res as any).value; - - const isEventPresent = actualEvents.some(actualEvent => - actualEvent[0] === expectedEvent[0] && - actualEvent[1] === expectedEvent[1] && - actualEvent[2] === expectedEvent[2] && - actualEvent[3].startsWith("with_version_") - ); - - browser.assert.ok(isEventPresent, 'Matomo compiler events are tracked correctly'); - }) - }, -} diff --git a/apps/remix-ide/src/app.ts b/apps/remix-ide/src/app.ts index 6f524ae840e..8b9b1c91032 100644 --- a/apps/remix-ide/src/app.ts +++ b/apps/remix-ide/src/app.ts @@ -94,6 +94,7 @@ import Config from './config' import FileManager from './app/files/fileManager' import FileProvider from "./app/files/fileProvider" import { appPlatformTypes } from '@remix-ui/app' +import { MatomoEvent, AppEvents, MatomoManagerEvents } from '@remix-api' import DGitProvider from './app/files/dgitProvider' import WorkspaceFileProvider from './app/files/workspaceFileProvider' @@ -112,7 +113,7 @@ import TabProxy from './app/panels/tab-proxy.js' import { Plugin } from '@remixproject/engine' import BottomBarPanel from './app/components/bottom-bar-panel' -const _paq = (window._paq = window._paq || []) +// Tracking now handled by this.track() method using MatomoManager export class platformApi { get name() { @@ -160,6 +161,18 @@ class AppComponent { settings: SettingsTab params: any desktopClientMode: boolean + + // Tracking method that uses the global MatomoManager instance + track(event: MatomoEvent) { + try { + const matomoManager = window._matomoManagerInstance + if (matomoManager && matomoManager.trackEvent) { + matomoManager.trackEvent(event) + } + } catch (error) { + console.debug('Tracking error:', error) + } + } constructor() { const PlatFormAPi = new platformApi() Registry.getInstance().put({ @@ -217,36 +230,23 @@ class AppComponent { this.workspace = pluginLoader.get() if (pluginLoader.current === 'queryParams') { this.workspace.map((workspace) => { - _paq.push(['trackEvent', 'App', 'queryParams-activated', workspace]) + this.track(AppEvents.queryParamsActivated(workspace)) }) } this.engine = new RemixEngine() this.engine.register(appManager) - const matomoDomains = { - 'alpha.remix.live': 27, - 'beta.remix.live': 25, - 'remix.ethereum.org': 23, - '6fd22d6fe5549ad4c4d8fd3ca0b7816b.mod': 35 // remix desktop - } - - // _paq.push(['trackEvent', 'App', 'load']); - this.matomoConfAlreadySet = Registry.getInstance().get('config').api.exists('settings/matomo-perf-analytics') - this.matomoCurrentSetting = Registry.getInstance().get('config').api.get('settings/matomo-perf-analytics') - - const electronTracking = (window as any).electronAPI ? await (window as any).electronAPI.canTrackMatomo() : false - - const lastMatomoCheck = window.localStorage.getItem('matomo-analytics-consent') - const sixMonthsAgo = new Date(); - sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + // Check if we should show the Matomo consent dialog using the MatomoManager + const matomoManager = (window as any)._matomoManagerInstance; + const configApi = Registry.getInstance().get('config').api; + this.showMatomo = matomoManager ? matomoManager.shouldShowConsentDialog(configApi) : false; - const e2eforceMatomoToShow = window.localStorage.getItem('showMatomo') && window.localStorage.getItem('showMatomo') === 'true' - const contextShouldShowMatomo = matomoDomains[window.location.hostname] || e2eforceMatomoToShow || electronTracking - const shouldRenewConsent = this.matomoCurrentSetting === false && (!lastMatomoCheck || new Date(Number(lastMatomoCheck)) < sixMonthsAgo) // it is set to false for more than 6 months. - this.showMatomo = contextShouldShowMatomo && (!this.matomoConfAlreadySet || shouldRenewConsent) + // Store config values for backwards compatibility + this.matomoConfAlreadySet = configApi.exists('settings/matomo-perf-analytics'); + this.matomoCurrentSetting = configApi.get('settings/matomo-perf-analytics'); - if (this.showMatomo && shouldRenewConsent) { - _paq.push(['trackEvent', 'Matomo', 'refreshMatomoPermissions']); + if (this.showMatomo) { + this.track(MatomoManagerEvents.showConsentDialog()); } this.walkthroughService = new WalkthroughService(appManager) @@ -685,7 +685,7 @@ class AppComponent { if (callDetails.length > 1) { this.appManager.call('notification', 'toast', `initiating ${callDetails[0]} and calling "${callDetails[1]}" ...`) // @todo(remove the timeout when activatePlugin is on 0.3.0) - _paq.push(['trackEvent', 'App', 'queryParams-calls', this.params.call]) + this.track(AppEvents.queryParamsCalls(this.params.call)) //@ts-ignore await this.appManager.call(...callDetails).catch(console.error) } @@ -696,7 +696,7 @@ class AppComponent { // call all functions in the list, one after the other for (const call of calls) { - _paq.push(['trackEvent', 'App', 'queryParams-calls', call]) + this.track(AppEvents.queryParamsCalls(call)) const callDetails = call.split('//') if (callDetails.length > 1) { this.appManager.call('notification', 'toast', `initiating ${callDetails[0]} and calling "${callDetails[1]}" ...`) diff --git a/apps/remix-ide/src/app/components/plugin-manager-component.tsx b/apps/remix-ide/src/app/components/plugin-manager-component.tsx index 55bff7f2559..7f3fadc3d78 100644 --- a/apps/remix-ide/src/app/components/plugin-manager-component.tsx +++ b/apps/remix-ide/src/app/components/plugin-manager-component.tsx @@ -3,10 +3,10 @@ import React from 'react' // eslint-disable-line import { RemixUiPluginManager } from '@remix-ui/plugin-manager' // eslint-disable-line import * as packageJson from '../../../../../package.json' import { PluginViewWrapper } from '@remix-ui/helper' +import { trackMatomoEvent, ManagerEvents } from '@remix-api' import { Profile } from '@remixproject/plugin-utils' import { RemixAppManager } from '../../remixAppManager' import { RemixEngine } from '../../remixEngine' -const _paq = window._paq = window._paq || [] const profile = { name: 'pluginManager', @@ -43,7 +43,6 @@ export class PluginManagerComponent extends ViewPlugin { this.activePlugins = [] this.inactivePlugins = [] this.activeProfiles = this.appManager.actives - this._paq = _paq this.dispatch = null this.listenOnEvent() } @@ -65,7 +64,7 @@ export class PluginManagerComponent extends ViewPlugin { */ activateP = (name) => { this.appManager.activatePlugin(name) - _paq.push(['trackEvent', 'manager', 'activate', name]) + trackMatomoEvent(this, ManagerEvents.activate(name)) } /** @@ -91,7 +90,7 @@ export class PluginManagerComponent extends ViewPlugin { */ deactivateP = (name) => { this.call('manager', 'deactivatePlugin', name) - _paq.push(['trackEvent', 'manager', 'deactivate', name]) + trackMatomoEvent(this, ManagerEvents.deactivate(name)) } setDispatch (dispatch) { diff --git a/apps/remix-ide/src/app/components/preload.tsx b/apps/remix-ide/src/app/components/preload.tsx index 16f2d08cd8b..0f80b848b0c 100644 --- a/apps/remix-ide/src/app/components/preload.tsx +++ b/apps/remix-ide/src/app/components/preload.tsx @@ -1,6 +1,9 @@ import { RemixApp } from '@remix-ui/app' import axios from 'axios' -import React, { useEffect, useRef, useState } from 'react' +import React, { useState, useEffect, useRef, useContext } from 'react' +import { FormattedMessage, useIntl } from 'react-intl' +import { useTracking, TrackingProvider } from '../contexts/TrackingContext' +import { TrackingFunction } from '../utils/TrackingFunction' import * as packageJson from '../../../../../package.json' import { fileSystem, fileSystems } from '../files/fileSystem' import { indexedDBFileSystem } from '../files/filesystems/indexedDB' @@ -8,11 +11,17 @@ import { localStorageFS } from '../files/filesystems/localStorage' import { fileSystemUtility, migrationTestData } from '../files/filesystems/fileSystemUtility' import './styles/preload.css' import isElectron from 'is-electron' -const _paq = (window._paq = window._paq || []) +import { AppEvents, MigrateEvents, StorageEvents } from '@remix-api' // _paq.push(['trackEvent', 'App', 'Preload', 'start']) -export const Preload = (props: any) => { +interface PreloadProps { + root: any; + trackingFunction: TrackingFunction; +} + +export const Preload = (props: PreloadProps) => { + const { trackMatomoEvent } = useTracking() const [tip, setTip] = useState('') const [supported, setSupported] = useState(true) const [error, setError] = useState(false) @@ -36,11 +45,15 @@ export const Preload = (props: any) => { .then((AppComponent) => { const appComponent = new AppComponent.default() appComponent.run().then(() => { - props.root.render() + props.root.render( + + + + ) }) }) .catch((err) => { - _paq.push(['trackEvent', 'App', 'PreloadError', err && err.message]) + trackMatomoEvent?.(AppEvents.PreloadError(err && err.message)) console.error('Error loading Remix:', err) setError(true) }) @@ -57,7 +70,7 @@ export const Preload = (props: any) => { setShowDownloader(false) const fsUtility = new fileSystemUtility() const migrationResult = await fsUtility.migrate(localStorageFileSystem.current, remixIndexedDB.current) - _paq.push(['trackEvent', 'Migrate', 'result', migrationResult ? 'success' : 'fail']) + trackMatomoEvent?.(MigrateEvents.result(migrationResult ? 'success' : 'fail')) await setFileSystems() } @@ -68,10 +81,10 @@ export const Preload = (props: any) => { ]) if (fsLoaded) { console.log(fsLoaded.name + ' activated') - _paq.push(['trackEvent', 'Storage', 'activate', fsLoaded.name]) + trackMatomoEvent?.(StorageEvents.activate(fsLoaded.name)) loadAppComponent() } else { - _paq.push(['trackEvent', 'Storage', 'error', 'no supported storage']) + trackMatomoEvent?.(StorageEvents.error('no supported storage')) setSupported(false) } } @@ -89,8 +102,8 @@ export const Preload = (props: any) => { return } async function loadStorage() { - ;(await remixFileSystems.current.addFileSystem(remixIndexedDB.current)) || _paq.push(['trackEvent', 'Storage', 'error', 'indexedDB not supported']) - ;(await remixFileSystems.current.addFileSystem(localStorageFileSystem.current)) || _paq.push(['trackEvent', 'Storage', 'error', 'localstorage not supported']) + ;(await remixFileSystems.current.addFileSystem(remixIndexedDB.current)) || trackMatomoEvent?.(StorageEvents.error('indexedDB not supported')) + ;(await remixFileSystems.current.addFileSystem(localStorageFileSystem.current)) || trackMatomoEvent?.(StorageEvents.error('localstorage not supported')) await testmigration() remixIndexedDB.current.loaded && (await remixIndexedDB.current.checkWorkspaces()) localStorageFileSystem.current.loaded && (await localStorageFileSystem.current.checkWorkspaces()) diff --git a/apps/remix-ide/src/app/contexts/TrackingContext.tsx b/apps/remix-ide/src/app/contexts/TrackingContext.tsx new file mode 100644 index 00000000000..6be3e992b40 --- /dev/null +++ b/apps/remix-ide/src/app/contexts/TrackingContext.tsx @@ -0,0 +1,36 @@ +import { MatomoEvent } from '@remix-api' +import React, { createContext, useContext, ReactNode } from 'react' + +export interface TrackingContextType { + trackMatomoEvent?: (event: MatomoEvent) => void +} + +const TrackingContext = createContext({}) + +interface TrackingProviderProps { + children: ReactNode + trackingFunction?: (event: MatomoEvent) => void +} + +export const TrackingProvider: React.FC = ({ + children, + trackingFunction +}) => { + return ( + + {children} + + ) +} + +export const useTracking = () => { + return useContext(TrackingContext) +} + +// Unified tracking hook that provides consistent access to tracking throughout the app +export const useAppTracking = () => { + return useTracking() +} + +export { TrackingContext } +export default TrackingContext \ No newline at end of file diff --git a/apps/remix-ide/src/app/files/filesystems/fileSystemUtility.ts b/apps/remix-ide/src/app/files/filesystems/fileSystemUtility.ts index ecbd4f55274..86879e1cb9d 100644 --- a/apps/remix-ide/src/app/files/filesystems/fileSystemUtility.ts +++ b/apps/remix-ide/src/app/files/filesystems/fileSystemUtility.ts @@ -1,7 +1,20 @@ import { hashMessage } from "ethers" import JSZip from "jszip" import { fileSystem } from "../fileSystem" -const _paq = window._paq = window._paq || [] +import { MigrateEvents, BackupEvents } from '@remix-api' + +import type { MatomoEvent } from '@remix-api' + +// Helper function to track events using MatomoManager instance +function track(event: MatomoEvent) { + try { + if (typeof window !== 'undefined' && window._matomoManagerInstance) { + window._matomoManagerInstance.trackEvent(event) + } + } catch (error) { + // Silent fail for tracking + } +} export class fileSystemUtility { migrate = async (fsFrom: fileSystem, fsTo: fileSystem) => { try { @@ -26,14 +39,14 @@ export class fileSystemUtility { console.log('file migration successful') return true } else { - _paq.push(['trackEvent', 'Migrate', 'error', 'hash mismatch']) + track(MigrateEvents.error('hash mismatch')) console.log('file migration failed falling back to ' + fsFrom.name) fsTo.loaded = false return false } } catch (err) { console.log(err) - _paq.push(['trackEvent', 'Migrate', 'error', err && err.message]) + track(MigrateEvents.error(err && err.message)) console.log('file migration failed falling back to ' + fsFrom.name) fsTo.loaded = false return false @@ -53,9 +66,9 @@ export class fileSystemUtility { const date = today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate() const time = today.getHours() + 'h' + today.getMinutes() + 'min' this.saveAs(blob, `remix-backup-at-${time}-${date}.zip`) - _paq.push(['trackEvent','Backup','download','preload']) + track(BackupEvents.download('preload')) } catch (err) { - _paq.push(['trackEvent','Backup','error',err && err.message]) + track(BackupEvents.error(err && err.message)) console.log(err) } } diff --git a/apps/remix-ide/src/app/matomo/BotDetector.ts b/apps/remix-ide/src/app/matomo/BotDetector.ts new file mode 100644 index 00000000000..ae169c1fe69 --- /dev/null +++ b/apps/remix-ide/src/app/matomo/BotDetector.ts @@ -0,0 +1,699 @@ +/** + * BotDetector - Comprehensive bot and automation detection utility + * + * Detects various types of bots including: + * - Search engine crawlers (Google, Bing, etc.) + * - Social media bots (Facebook, Twitter, etc.) + * - Monitoring services (UptimeRobot, Pingdom, etc.) + * - Headless browsers (Puppeteer, Playwright, Selenium) + * - AI scrapers (ChatGPT, Claude, etc.) + * + * Detection methods: + * 1. User Agent string analysis + * 2. Browser automation flags (navigator.webdriver) + * 3. Headless browser detection + * 4. Missing browser features + * 5. Behavioral signals + * 6. Mouse movement analysis + */ + +export interface BotDetectionResult { + isBot: boolean; + botType?: string; + confidence: 'high' | 'medium' | 'low'; + reasons: string[]; + userAgent: string; + mouseAnalysis?: MouseBehaviorAnalysis; +} + +export interface MouseBehaviorAnalysis { + hasMoved: boolean; + movements: number; + averageSpeed: number; + maxSpeed: number; + hasAcceleration: boolean; + hasCurvedPath: boolean; + suspiciousPatterns: string[]; + humanLikelihood: 'high' | 'medium' | 'low' | 'unknown'; +} + +// ================== MOUSE TRACKING CLASS ================== + +/** + * MouseTracker - Analyzes mouse movement patterns to detect bots + * + * Tracks: + * - Movement frequency and speed + * - Acceleration/deceleration patterns + * - Path curvature (humans rarely move in straight lines) + * - Micro-movements and jitter (humans have natural hand tremor) + * - Click patterns (timing, position accuracy) + */ +class MouseTracker { + private movements: Array<{ x: number; y: number; timestamp: number }> = []; + private clicks: Array<{ x: number; y: number; timestamp: number }> = []; + private lastPosition: { x: number; y: number } | null = null; + private startTime: number = Date.now(); + private isTracking: boolean = false; + + private readonly MAX_MOVEMENTS = 100; // Keep last 100 movements + private readonly SAMPLING_INTERVAL = 50; // Sample every 50ms + + private mouseMoveHandler: ((e: MouseEvent) => void) | null = null; + private mouseClickHandler: ((e: MouseEvent) => void) | null = null; + + /** + * Start tracking mouse movements + */ + start(): void { + if (this.isTracking) return; + + this.mouseMoveHandler = (e: MouseEvent) => { + const now = Date.now(); + + // Throttle to sampling interval + const lastMovement = this.movements[this.movements.length - 1]; + if (lastMovement && now - lastMovement.timestamp < this.SAMPLING_INTERVAL) { + return; + } + + this.movements.push({ + x: e.clientX, + y: e.clientY, + timestamp: now, + }); + + // Keep only recent movements + if (this.movements.length > this.MAX_MOVEMENTS) { + this.movements.shift(); + } + + this.lastPosition = { x: e.clientX, y: e.clientY }; + }; + + this.mouseClickHandler = (e: MouseEvent) => { + this.clicks.push({ + x: e.clientX, + y: e.clientY, + timestamp: Date.now(), + }); + + // Keep only recent clicks + if (this.clicks.length > 20) { + this.clicks.shift(); + } + }; + + document.addEventListener('mousemove', this.mouseMoveHandler, { passive: true }); + document.addEventListener('click', this.mouseClickHandler, { passive: true }); + this.isTracking = true; + } + + /** + * Stop tracking and clean up + */ + stop(): void { + if (!this.isTracking) return; + + if (this.mouseMoveHandler) { + document.removeEventListener('mousemove', this.mouseMoveHandler); + } + if (this.mouseClickHandler) { + document.removeEventListener('click', this.mouseClickHandler); + } + + this.isTracking = false; + } + + /** + * Analyze collected mouse data + */ + analyze(): MouseBehaviorAnalysis { + const suspiciousPatterns: string[] = []; + let humanLikelihood: 'high' | 'medium' | 'low' | 'unknown' = 'unknown'; + + // Not enough data yet - return early + if (this.movements.length < 5) { + return { + hasMoved: this.movements.length > 0, + movements: this.movements.length, + averageSpeed: 0, + maxSpeed: 0, + hasAcceleration: false, + hasCurvedPath: false, + suspiciousPatterns: [], + humanLikelihood: 'unknown', + }; + } + + // Calculate speeds (optimized single pass) + const speeds: number[] = []; + let totalSpeed = 0; + let maxSpeed = 0; + + for (let i = 1; i < this.movements.length; i++) { + const prev = this.movements[i - 1]; + const curr = this.movements[i]; + const dx = curr.x - prev.x; + const dy = curr.y - prev.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const time = (curr.timestamp - prev.timestamp) / 1000; + const speed = time > 0 ? distance / time : 0; + + speeds.push(speed); + totalSpeed += speed; + if (speed > maxSpeed) maxSpeed = speed; + } + + const averageSpeed = totalSpeed / speeds.length; + + // Check for acceleration/deceleration + let hasAcceleration = false; + let accelerationChanges = 0; + const threshold = averageSpeed * 0.3; + + for (let i = 1; i < speeds.length; i++) { + if (Math.abs(speeds[i] - speeds[i - 1]) > threshold) { + accelerationChanges++; + } + } + hasAcceleration = accelerationChanges > speeds.length * 0.2; + + // Check for curved paths (humans rarely move in straight lines) + let hasCurvedPath = false; + if (this.movements.length >= 10) { + const angles: number[] = []; + for (let i = 2; i < this.movements.length; i++) { + const p1 = this.movements[i - 2]; + const p2 = this.movements[i - 1]; + const p3 = this.movements[i]; + + const angle1 = Math.atan2(p2.y - p1.y, p2.x - p1.x); + const angle2 = Math.atan2(p3.y - p2.y, p3.x - p2.x); + const angleDiff = Math.abs(angle2 - angle1); + angles.push(angleDiff); + } + + const averageAngleChange = angles.reduce((a, b) => a + b, 0) / angles.length; + hasCurvedPath = averageAngleChange > 0.1; // More than 5.7 degrees average change + } + + // Detect suspicious patterns + + // 1. Perfectly straight lines (bot characteristic) + if (!hasCurvedPath && this.movements.length >= 10) { + suspiciousPatterns.push('perfectly-straight-movements'); + } + + // 2. Constant speed (bots don't accelerate naturally) + if (!hasAcceleration && speeds.length >= 10) { + const speedVariance = speeds.reduce((sum, speed) => sum + Math.pow(speed - averageSpeed, 2), 0) / speeds.length; + if (speedVariance < averageSpeed * 0.1) { + suspiciousPatterns.push('constant-speed'); + } + } + + // 3. Extremely fast movements (teleporting) + if (maxSpeed > 5000) { + // More than 5000 px/s is suspicious + suspiciousPatterns.push('unrealistic-speed'); + } + + // 4. No mouse movement at all (headless browser) + if (this.movements.length === 0 && Date.now() - this.startTime > 5000) { + suspiciousPatterns.push('no-mouse-activity'); + } + + // 5. Robotic click patterns (perfectly timed clicks) + if (this.clicks.length >= 3) { + const clickIntervals: number[] = []; + for (let i = 1; i < this.clicks.length; i++) { + clickIntervals.push(this.clicks[i].timestamp - this.clicks[i - 1].timestamp); + } + + // Check if clicks are too evenly spaced (bot pattern) + const avgInterval = clickIntervals.reduce((a, b) => a + b, 0) / clickIntervals.length; + const intervalVariance = clickIntervals.reduce((sum, interval) => + sum + Math.pow(interval - avgInterval, 2), 0) / clickIntervals.length; + + if (intervalVariance < 100) { + // Less than 100ms² variance = too consistent + suspiciousPatterns.push('robotic-click-timing'); + } + } + + // 6. Grid-aligned movements (bot snapping to pixel grid) + if (this.movements.length >= 20) { + let gridAligned = 0; + for (const movement of this.movements) { + if (movement.x % 10 === 0 && movement.y % 10 === 0) { + gridAligned++; + } + } + if (gridAligned > this.movements.length * 0.5) { + suspiciousPatterns.push('grid-aligned-movements'); + } + } + + // Calculate human likelihood + if (suspiciousPatterns.length === 0 && hasAcceleration && hasCurvedPath) { + humanLikelihood = 'high'; + } else if (suspiciousPatterns.length <= 1 && (hasAcceleration || hasCurvedPath)) { + humanLikelihood = 'medium'; + } else if (suspiciousPatterns.length >= 2) { + humanLikelihood = 'low'; + } + + return { + hasMoved: this.movements.length > 0, + movements: this.movements.length, + averageSpeed, + maxSpeed, + hasAcceleration, + hasCurvedPath, + suspiciousPatterns, + humanLikelihood, + }; + } +} + +// ================== BOT DETECTOR CLASS ================== + +export class BotDetector { + // Mouse tracking state + private static mouseTracker: MouseTracker | null = null; + + // Common bot patterns in user agents + private static readonly BOT_PATTERNS = [ + // Search engine crawlers + /googlebot/i, + /bingbot/i, + /slurp/i, // Yahoo + /duckduckbot/i, + /baiduspider/i, + /yandexbot/i, + /sogou/i, + /exabot/i, + + // Social media bots + /facebookexternalhit/i, + /twitterbot/i, + /linkedinbot/i, + /pinterest/i, + /whatsapp/i, + /telegrambot/i, + + // Monitoring services + /uptimerobot/i, + /pingdom/i, + /newrelic/i, + /gtmetrix/i, + /lighthouse/i, + + // SEO tools + /ahrefsbot/i, + /semrushbot/i, + /mj12bot/i, + /dotbot/i, + /screaming frog/i, + + // AI scrapers + /chatgpt-user/i, + /gptbot/i, + /claudebot/i, + /anthropic-ai/i, + /cohere-ai/i, + /perplexity/i, + + // Generic bot indicators + /bot/i, + /crawler/i, + /spider/i, + /scraper/i, + /curl/i, + /wget/i, + /python-requests/i, + /go-http-client/i, + /axios/i, + + // Headless browsers + /headlesschrome/i, + /phantomjs/i, + /htmlunit/i, + /splashhttp/i, + ]; + + // Automation frameworks + private static readonly AUTOMATION_PATTERNS = [ + /puppeteer/i, + /playwright/i, + /selenium/i, + /webdriver/i, + /chromedriver/i, + /geckodriver/i, + /automation/i, + ]; + + /** + * Perform comprehensive bot detection + * @param includeMouseTracking - Whether to include mouse behavior analysis (default: true) + */ + static detect(includeMouseTracking: boolean = true): BotDetectionResult { + const userAgent = navigator.userAgent; + const reasons: string[] = []; + let isBot = false; + let botType: string | undefined; + let confidence: 'high' | 'medium' | 'low' = 'low'; + + // Check 1: User agent pattern matching + const uaCheck = this.checkUserAgent(userAgent); + if (uaCheck.isBot) { + isBot = true; + botType = uaCheck.botType; + confidence = 'high'; + reasons.push(`User agent matches bot pattern: ${uaCheck.botType}`); + } + + // Check 2: Automation flags (very reliable) + if (this.checkAutomationFlags()) { + isBot = true; + botType = botType || 'automation'; + confidence = 'high'; + reasons.push('Browser automation detected (navigator.webdriver or similar)'); + } + + // Check 3: Headless browser detection + const headlessCheck = this.checkHeadlessBrowser(); + if (headlessCheck.isHeadless) { + isBot = true; + botType = botType || 'headless'; + confidence = confidence === 'low' ? 'medium' : confidence; + reasons.push(...headlessCheck.reasons); + } + + // Check 4: Missing features (medium confidence) + const missingFeatures = this.checkMissingFeatures(); + if (missingFeatures.length > 0) { + if (missingFeatures.length >= 3) { + isBot = true; + botType = botType || 'suspicious'; + confidence = confidence === 'low' ? 'medium' : confidence; + } + reasons.push(`Missing browser features: ${missingFeatures.join(', ')}`); + } + + // Check 5: Behavioral signals (low confidence, just log) + const behavioralSignals = this.checkBehavioralSignals(); + if (behavioralSignals.length > 0) { + reasons.push(`Behavioral signals: ${behavioralSignals.join(', ')}`); + } + + // Check 6: Mouse behavior analysis (if enabled and tracker initialized) + let mouseAnalysis: MouseBehaviorAnalysis | undefined; + if (includeMouseTracking) { + if (!this.mouseTracker) { + // Initialize mouse tracking on first detection + this.mouseTracker = new MouseTracker(); + this.mouseTracker.start(); + } + + mouseAnalysis = this.mouseTracker.analyze(); + + // Adjust bot detection based on mouse behavior + if (mouseAnalysis.hasMoved && mouseAnalysis.humanLikelihood === 'high') { + // Strong evidence of human behavior + if (confidence === 'low') { + reasons.push('Mouse behavior indicates human user'); + } + } else if (mouseAnalysis.suspiciousPatterns.length > 0) { + // Suspicious mouse patterns suggest bot + if (!isBot && mouseAnalysis.suspiciousPatterns.length >= 2) { + isBot = true; + botType = botType || 'suspicious-mouse-behavior'; + confidence = 'medium'; + } + reasons.push(`Suspicious mouse patterns: ${mouseAnalysis.suspiciousPatterns.join(', ')}`); + } + } + + return { + isBot, + botType, + confidence, + reasons, + userAgent, + mouseAnalysis, + }; + } + + /** + * Check user agent string for known bot patterns + */ + private static checkUserAgent(userAgent: string): { isBot: boolean; botType?: string } { + // Check bot patterns + for (const pattern of this.BOT_PATTERNS) { + if (pattern.test(userAgent)) { + const match = userAgent.match(pattern); + return { + isBot: true, + botType: match ? match[0].toLowerCase() : 'unknown-bot', + }; + } + } + + // Check automation patterns + for (const pattern of this.AUTOMATION_PATTERNS) { + if (pattern.test(userAgent)) { + const match = userAgent.match(pattern); + return { + isBot: true, + botType: match ? `automation-${match[0].toLowerCase()}` : 'automation', + }; + } + } + + return { isBot: false }; + } + + /** + * Check for browser automation flags + */ + private static checkAutomationFlags(): boolean { + // Most reliable indicator - WebDriver flag + if (navigator.webdriver) { + return true; + } + + // Check for Selenium/WebDriver artifacts + if ((window as any).__webdriver_evaluate || + (window as any).__selenium_evaluate || + (window as any).__webdriver_script_function || + (window as any).__webdriver_script_func || + (window as any).__selenium_unwrapped || + (window as any).__fxdriver_evaluate || + (window as any).__driver_unwrapped || + (window as any).__webdriver_unwrapped || + (window as any).__driver_evaluate || + (window as any).__fxdriver_unwrapped) { + return true; + } + + // Check document properties + if ((document as any).__webdriver_evaluate || + (document as any).__selenium_evaluate || + (document as any).__webdriver_unwrapped || + (document as any).__driver_unwrapped) { + return true; + } + + // Check for common automation framework artifacts + if ((window as any)._phantom || + (window as any).callPhantom || + (window as any)._Selenium_IDE_Recorder) { + return true; + } + + return false; + } + + /** + * Detect headless browser + */ + private static checkHeadlessBrowser(): { isHeadless: boolean; reasons: string[] } { + const reasons: string[] = []; + let isHeadless = false; + + // Check for headless Chrome/Chromium + if (navigator.userAgent.includes('HeadlessChrome')) { + isHeadless = true; + reasons.push('HeadlessChrome in user agent'); + } + + // Chrome headless has no plugins + if (navigator.plugins?.length === 0 && /Chrome/.test(navigator.userAgent)) { + isHeadless = true; + reasons.push('No plugins in Chrome (headless indicator)'); + } + + // Check for missing webGL vendor + try { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + if (gl) { + const debugInfo = (gl as any).getExtension('WEBGL_debug_renderer_info'); + if (debugInfo) { + const vendor = (gl as any).getParameter(debugInfo.UNMASKED_VENDOR_WEBGL); + const renderer = (gl as any).getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); + + // Headless browsers often have 'SwiftShader' or generic renderers + if (vendor?.includes('Google') && renderer?.includes('SwiftShader')) { + isHeadless = true; + reasons.push('SwiftShader renderer (headless indicator)'); + } + } + } + } catch (e) { + // Ignore errors + } + + // Check chrome object in window (present in headless) + if ((window as any).chrome && !(window as any).chrome.runtime) { + reasons.push('Incomplete chrome object'); + } + + // Check permissions API + try { + if (navigator.permissions) { + navigator.permissions.query({ name: 'notifications' as PermissionName }).then((result) => { + if (result.state === 'denied' && !('Notification' in window)) { + reasons.push('Permissions API mismatch'); + } + }).catch(() => {}); + } + } catch (e) { + // Ignore errors + } + + return { isHeadless, reasons }; + } + + /** + * Check for missing browser features that real users typically have + */ + private static checkMissingFeatures(): string[] { + const missing: string[] = []; + + // Check for basic browser features + if (typeof navigator.languages === 'undefined' || navigator.languages.length === 0) { + missing.push('languages'); + } + + if (typeof navigator.platform === 'undefined') { + missing.push('platform'); + } + + if (typeof navigator.plugins === 'undefined') { + missing.push('plugins'); + } + + if (typeof navigator.mimeTypes === 'undefined') { + missing.push('mimeTypes'); + } + + // Check for touch support (many bots don't emulate this properly) + const isMobileUA = /Mobile|Android|iPhone|iPad/i.test(navigator.userAgent); + if (!('ontouchstart' in window) && + !('maxTouchPoints' in navigator) && + isMobileUA) { + missing.push('touch-support-mobile'); + } + + // Check for connection API + if (!('connection' in navigator) && !('mozConnection' in navigator) && !('webkitConnection' in navigator)) { + missing.push('connection-api'); + } + + return missing; + } + + /** + * Check behavioral signals (patterns that suggest automated behavior) + */ + private static checkBehavioralSignals(): string[] { + const signals: string[] = []; + + // Check screen dimensions (some bots have weird screen sizes) + if (screen.width === 0 || screen.height === 0) { + signals.push('zero-screen-dimensions'); + } + + // Check for very small viewport (unusual for real users) + if (window.innerWidth < 100 || window.innerHeight < 100) { + signals.push('tiny-viewport'); + } + + // Check for suspiciously fast page load (some bots don't wait for DOMContentLoaded properly) + if (document.readyState === 'loading' && performance.now() < 100) { + signals.push('very-fast-load'); + } + + // Check for missing referer on non-direct navigation + if (!document.referrer && window.history.length > 1) { + signals.push('missing-referrer'); + } + + return signals; + } + + /** + * Quick check - just returns boolean without full analysis + */ + static isBot(): boolean { + return this.detect().isBot; + } + + /** + * Get a simple string representation of bot type for Matomo dimension + */ + static getBotTypeString(): string { + const result = this.detect(); + if (!result.isBot) { + return 'human'; + } + return result.botType || 'unknown-bot'; + } + + /** + * Get confidence level of detection + */ + static getConfidence(): 'high' | 'medium' | 'low' { + return this.detect().confidence; + } + + /** + * Start mouse tracking (if not already started) + */ + static startMouseTracking(): void { + if (!this.mouseTracker) { + this.mouseTracker = new MouseTracker(); + this.mouseTracker.start(); + } + } + + /** + * Stop mouse tracking and clean up + */ + static stopMouseTracking(): void { + if (this.mouseTracker) { + this.mouseTracker.stop(); + this.mouseTracker = null; + } + } + + /** + * Get current mouse behavior analysis + */ + static getMouseAnalysis(): MouseBehaviorAnalysis | null { + return this.mouseTracker?.analyze() || null; + } +} diff --git a/apps/remix-ide/src/app/matomo/MatomoAutoInit.ts b/apps/remix-ide/src/app/matomo/MatomoAutoInit.ts new file mode 100644 index 00000000000..b68425bc7e4 --- /dev/null +++ b/apps/remix-ide/src/app/matomo/MatomoAutoInit.ts @@ -0,0 +1,135 @@ +/** + * MatomoAutoInit - Handles automatic Matomo initialization based on existing user settings + * + * This module provides automatic initialization of Matomo tracking when users have + * previously made consent choices, eliminating the need to show consent dialogs + * for returning users while respecting their privacy preferences. + */ + +import { MatomoManager } from './MatomoManager'; + +import Config from '../../config'; +import { Registry } from '@remix-project/remix-lib'; +import { Storage } from '@remix-project/remix-lib'; + +export interface MatomoAutoInitOptions { + matomoManager: MatomoManager; + debug?: boolean; +} + +/** + * Setup configuration and registry, then automatically initialize Matomo if user has existing settings + * + * @param options Configuration object containing MatomoManager instance + * @returns Promise - true if auto-initialization occurred, false otherwise + */ +export async function autoInitializeMatomo(options: MatomoAutoInitOptions): Promise { + const { matomoManager, debug = false } = options; + + const log = (message: string, ...args: any[]) => { + if (debug) { + console.log(`[Matomo][AutoInit] ${message}`, ...args); + } + }; + + // Setup configuration and registry + let config: any; + try { + const configStorage = new Storage('config-v0.8:') + config = new Config(configStorage) + Registry.getInstance().put({ api: config, name: 'config' }) + log('Config setup completed'); + } catch (e) { + log('Config setup failed:', e); + } + + try { + // Check if we should show the consent dialog + const shouldShowDialog = matomoManager.shouldShowConsentDialog(config); + + if (!shouldShowDialog && config) { + // User has made their choice before, initialize automatically + const perfAnalyticsEnabled = config.get('settings/matomo-perf-analytics'); + log('Auto-initializing with existing settings, perf analytics:', perfAnalyticsEnabled); + + if (perfAnalyticsEnabled === true) { + // User enabled performance analytics = cookie mode + await matomoManager.initialize('immediate'); + log('Auto-initialized with immediate (cookie) mode'); + + // Process any queued tracking events + await matomoManager.processPreInitQueue(); + log('Pre-init queue processed'); + + return true; + + } else if (perfAnalyticsEnabled === false) { + // User disabled performance analytics = anonymous mode + await matomoManager.initialize('anonymous'); + log('Auto-initialized with anonymous mode'); + + // Process any queued tracking events + await matomoManager.processPreInitQueue(); + log('Pre-init queue processed'); + + return true; + } else { + log('No valid perf analytics setting found, skipping auto-initialization'); + return false; + } + + } else if (shouldShowDialog) { + log('Consent dialog will be shown, skipping auto-initialization'); + return false; + + } else { + log('No config available, skipping auto-initialization'); + return false; + } + + } catch (error) { + console.warn('[Matomo][AutoInit] Error during auto-initialization:', error); + return false; + } +} + +/** + * Get the current tracking mode based on existing configuration + * Useful for determining user's previous choice without initializing + */ +export function getCurrentTrackingMode(config?: any): 'cookie' | 'anonymous' | 'none' { + if (!config) { + return 'none'; + } + + try { + const perfAnalyticsEnabled = config.get('settings/matomo-perf-analytics'); + + if (perfAnalyticsEnabled === true) { + return 'cookie'; + } else if (perfAnalyticsEnabled === false) { + return 'anonymous'; + } else { + return 'none'; + } + } catch (error) { + console.warn('[Matomo][AutoInit] Error reading tracking mode:', error); + return 'none'; + } +} + +/** + * Check if user has made a previous tracking choice + */ +export function hasExistingTrackingChoice(config?: any): boolean { + if (!config) { + return false; + } + + try { + const perfAnalyticsSetting = config.get('settings/matomo-perf-analytics'); + return typeof perfAnalyticsSetting === 'boolean'; + } catch (error) { + return false; + } +} \ No newline at end of file diff --git a/apps/remix-ide/src/app/matomo/MatomoConfig.ts b/apps/remix-ide/src/app/matomo/MatomoConfig.ts new file mode 100644 index 00000000000..4c05753f292 --- /dev/null +++ b/apps/remix-ide/src/app/matomo/MatomoConfig.ts @@ -0,0 +1,180 @@ +/** + * Matomo Configuration Constants + * + * Single source of truth for Matomo site IDs and configuration + */ + +import { MatomoConfig } from './MatomoManager'; + +// ================ DEVELOPER CONFIGURATION ================ +/** + * Enable Matomo tracking on localhost for development and testing + * + * USAGE: + * - Set to `true` to enable Matomo on localhost/127.0.0.1 during development + * - Set to `false` (default) to disable Matomo on localhost (prevents CI test pollution) + * + * ALTERNATIVES: + * - You can also enable Matomo temporarily by setting localStorage.setItem('showMatomo', 'true') in browser console + * - The localStorage method is temporary (cleared on browser restart) + * - This config flag is permanent until you change it back + * + * IMPORTANT: + * - CircleCI tests automatically disable this through environment isolation + * - Production domains (remix.ethereum.org, etc.) are unaffected by this setting + * - Only affects localhost and 127.0.0.1 domains + */ +export const ENABLE_MATOMO_LOCALHOST = false; + +// Type for domain-specific custom dimensions +export interface DomainCustomDimensions { + trackingMode: number; // Dimension ID for 'anon'/'cookie' tracking mode + clickAction: number; // Dimension ID for 'true'/'false' click tracking + isBot: number; // Dimension ID for 'human'/'bot' detection +} + +// Type for domain keys (single source of truth) +export type MatomotDomain = 'alpha.remix.live' | 'beta.remix.live' | 'remix.ethereum.org' | 'localhost' | '127.0.0.1'; + +// Type for site ID configuration +export type SiteIdConfig = Record; + +// Type for bot site ID configuration (allows null for same-as-human) +export type BotSiteIdConfig = Record; + +// Type for custom dimensions configuration (enforces all domains have entries) +export type CustomDimensionsConfig = Record; + +// Type for bot custom dimensions configuration (allows null for same-as-human) +export type BotCustomDimensionsConfig = Record; + +// Single source of truth for Matomo site ids (matches loader.js.txt) +export const MATOMO_DOMAINS: SiteIdConfig = { + 'alpha.remix.live': 1, + 'beta.remix.live': 2, + 'remix.ethereum.org': 3, + 'localhost': 5, + '127.0.0.1': 5 +}; + +// Bot tracking site IDs (separate databases to avoid polluting human analytics) +// Set to null to use same site ID for bots (they'll be filtered via isBot dimension) +export const MATOMO_BOT_SITE_IDS: BotSiteIdConfig = { + 'alpha.remix.live': null, // TODO: Create bot tracking site in Matomo (e.g., site ID 10) + 'beta.remix.live': null, // TODO: Create bot tracking site in Matomo (e.g., site ID 11) + 'remix.ethereum.org': 8, // TODO: Create bot tracking site in Matomo (e.g., site ID 12) + 'localhost': 7, // Keep bots in same localhost site for testing (E2E tests need cookies) + '127.0.0.1': 7 // Keep bots in same localhost site for testing (E2E tests need cookies) +}; + +// Domain-specific custom dimension IDs for HUMAN traffic +// These IDs must match what's configured in each Matomo site +export const MATOMO_CUSTOM_DIMENSIONS: CustomDimensionsConfig = { + // Production domains + 'alpha.remix.live': { + trackingMode: 1, // Dimension for 'anon'/'cookie' tracking mode + clickAction: 2, // Dimension for 'true'/'false' click tracking + isBot: 3 // Dimension for 'human'/'bot'/'automation' detection + }, + 'beta.remix.live': { + trackingMode: 1, // Dimension for 'anon'/'cookie' tracking mode + clickAction: 2, // Dimension for 'true'/'false' click tracking + isBot: 3 // Dimension for 'human'/'bot'/'automation' detection + }, + 'remix.ethereum.org': { + trackingMode: 1, // Dimension for 'anon'/'cookie' tracking mode + clickAction: 2, // Dimension for 'true'/'false' click tracking + isBot: 3 // Dimension for 'human'/'bot'/'automation' detection + }, + // Development domains + localhost: { + trackingMode: 1, // Dimension for 'anon'/'cookie' tracking mode + clickAction: 3, // Dimension for 'true'/'false' click tracking + isBot: 4 // Dimension for 'human'/'bot'/'automation' detection + }, + '127.0.0.1': { + trackingMode: 1, // Dimension for 'anon'/'cookie' tracking mode + clickAction: 3, // Dimension for 'true'/'false' click tracking + isBot: 4 // Dimension for 'human'/'bot'/'automation' detection + } +}; + +// Domain-specific custom dimension IDs for BOT traffic (when using separate bot sites) +// These IDs must match what's configured in the bot tracking sites +// Set to null to use the same dimension IDs as human sites +export const MATOMO_BOT_CUSTOM_DIMENSIONS: BotCustomDimensionsConfig = { + 'alpha.remix.live': null, // TODO: Configure if bot site has different dimension IDs + 'beta.remix.live': null, // TODO: Configure if bot site has different dimension IDs + 'remix.ethereum.org': { + trackingMode: 1, + clickAction: 3, + isBot: 2 + }, + 'localhost': { + trackingMode: 1, + clickAction: 3, + isBot: 2 + }, + '127.0.0.1': { + trackingMode: 1, + clickAction: 3, + isBot: 2 + } +}; + +/** + * Get the appropriate site ID for the current domain and bot status + * + * @param isBot - Whether the visitor is detected as a bot + * @returns Site ID to use for tracking + */ +export function getSiteIdForTracking(isBot: boolean): number { + const hostname = window.location.hostname; + + // If bot and bot site ID is configured, use it + if (isBot && MATOMO_BOT_SITE_IDS[hostname] !== null && MATOMO_BOT_SITE_IDS[hostname] !== undefined) { + return MATOMO_BOT_SITE_IDS[hostname]; + } + + // Otherwise use normal site ID + return MATOMO_DOMAINS[hostname] || MATOMO_DOMAINS['localhost']; +} + +/** + * Get custom dimensions configuration for current domain + * + * @param isBot - Whether the visitor is detected as a bot (to use bot-specific dimensions if configured) + */ +export function getDomainCustomDimensions(isBot: boolean = false): DomainCustomDimensions { + const hostname = window.location.hostname; + + // If bot and bot-specific dimensions are configured, use them + if (isBot && MATOMO_BOT_CUSTOM_DIMENSIONS[hostname] !== null && MATOMO_BOT_CUSTOM_DIMENSIONS[hostname] !== undefined) { + return MATOMO_BOT_CUSTOM_DIMENSIONS[hostname]; + } + + // Return dimensions for current domain + if (MATOMO_CUSTOM_DIMENSIONS[hostname]) { + return MATOMO_CUSTOM_DIMENSIONS[hostname]; + } + + // Fallback to localhost if domain not found + console.warn(`No custom dimensions found for domain: ${hostname}, using localhost fallback`); + return MATOMO_CUSTOM_DIMENSIONS['localhost']; +} + +/** + * Create default Matomo configuration + */ +export function createMatomoConfig(): MatomoConfig { + return { + trackerUrl: 'https://matomo.remix.live/matomo/matomo.php', + // siteId will be auto-derived from matomoDomains based on current hostname + debug: false, + matomoDomains: MATOMO_DOMAINS, + scriptTimeout: 10000, + onStateChange: (event, data, state) => { + // hook into state changes if needed + } + }; +} \ No newline at end of file diff --git a/apps/remix-ide/src/app/matomo/MatomoManager.ts b/apps/remix-ide/src/app/matomo/MatomoManager.ts new file mode 100644 index 00000000000..3265213f240 --- /dev/null +++ b/apps/remix-ide/src/app/matomo/MatomoManager.ts @@ -0,0 +1,1681 @@ +/** + * MatomoManager - A comprehensive Matomo Analytics management class + * TypeScript version with async/await patterns and strong typing + * + * Features: + * - Multiple initialization patterns (consent-based, anonymous, immediate) + * - Detailed logging and debugging capabilities + * - Mode switching with proper state management + * - Cookie and consent lifecycle management + * - Event interception and monitoring + * + * Usage: + * const matomo = new MatomoManager({ + * trackerUrl: 'https://your-matomo.com/matomo.php', + * siteId: 1, + * debug: true + * }); + * + * await matomo.initialize('cookie-consent'); + * await matomo.switchMode('anonymous'); + * matomo.trackEvent('test', 'action', 'label'); + */ + +import { MatomoEvent } from '@remix-api'; +import { getDomainCustomDimensions, DomainCustomDimensions, ENABLE_MATOMO_LOCALHOST, getSiteIdForTracking } from './MatomoConfig'; +import { BotDetector, BotDetectionResult } from './BotDetector'; + +// ================== TYPE DEFINITIONS ================== + +export interface MatomoConfig { + trackerUrl: string; + siteId?: number; + debug?: boolean; + customDimensions?: Record; + onStateChange?: StateChangeHandler | null; + logPrefix?: string; + scriptTimeout?: number; + retryAttempts?: number; + matomoDomains?: Record; + mouseTrackingDelay?: number; // ms to wait for mouse movements before initializing (default: 2000) + waitForMouseTracking?: boolean; // Whether to delay init for mouse tracking (default: true) +} + +export interface MatomoState { + initialized: boolean; + scriptLoaded: boolean; + currentMode: InitializationPattern | null; + consentGiven: boolean; + lastEventId: number; + loadingPromise: Promise | null; +} + +export interface MatomoStatus { + matomoLoaded: boolean; + paqLength: number; + paqType: 'array' | 'object' | 'undefined'; + cookieCount: number; + cookies: string[]; +} + +export interface MatomoTracker { + getTrackerUrl(): string; + getSiteId(): number | string; + trackEvent(eventObj: MatomoEvent): void; + trackEvent(category: string, action: string, name?: string, value?: string | number): void; + trackPageView(title?: string): void; + trackSiteSearch(keyword: string, category?: string, count?: number): void; + trackGoal(goalId: number, value?: number): void; + trackLink(url: string, linkType: string): void; + trackDownload(url: string): void; + [key: string]: any; // Allow dynamic method calls +} + +export interface MatomoDiagnostics { + config: MatomoConfig; + state: MatomoState; + status: MatomoStatus; + tracker: { + url: string; + siteId: number | string; + } | null; + plugins: string[]; + userAgent: string; + timestamp: string; +} + +export type InitializationPattern = 'cookie-consent' | 'anonymous' | 'immediate' | 'no-consent'; +export type TrackingMode = 'cookie' | 'anonymous'; +export type MatomoCommand = [string, ...any[]]; +export type LogLevel = 'log' | 'debug' | 'warn' | 'error'; + +export interface InitializationOptions { + trackingMode?: boolean; + timeout?: number; + [key: string]: any; +} + +export interface ModeSwitchOptions { + forgetConsent?: boolean; + deleteCookies?: boolean; + setDimension?: boolean; + [key: string]: any; +} + +export interface PluginLoadOptions { + timeout?: number; + retryAttempts?: number; + onLoad?: () => void; + onError?: (error: Error) => void; + initFunction?: string; // Name of the global init function to call after loading +} + +export interface DebugPluginE2EHelpers { + getEvents: () => any[]; + getLatestEvent: () => any; + getEventsByCategory: (category: string) => any[]; + getEventsByAction: (action: string) => any[]; + getPageViews: () => any[]; + getVisitorIds: () => any[]; + getDimensions: () => Record; + clearData: () => void; + waitForEvent: (category?: string, action?: string, timeout?: number) => Promise; +} + +export interface EventData { + eventId: number; + category: string; + action: string; + name?: string; + value?: number; +} + +export interface LogData { + message: string; + data?: any; + timestamp: string; +} + +export type StateChangeHandler = (event: string, data: any, state: MatomoState & MatomoStatus) => void; +export type EventListener = (data: T) => void; + +// Global _paq interface +declare global { + interface Window { + _paq: any; + _matomoManagerInstance?: MatomoManager; + Matomo?: { + getTracker(): MatomoTracker; + }; + Piwik?: { + getTracker(): MatomoTracker; + }; + } +} + +// ================== MATOMO MANAGER INTERFACE ================== + +export interface IMatomoManager { + // Initialization methods + initialize(pattern?: InitializationPattern, options?: InitializationOptions): Promise; + + // Mode switching and consent management + switchMode(mode: TrackingMode, options?: ModeSwitchOptions & { processQueue?: boolean }): Promise; + giveConsent(options?: { processQueue?: boolean }): Promise; + revokeConsent(): Promise; + + // Tracking methods - both type-safe and legacy signatures supported + trackEvent(event: MatomoEvent): number; + trackEvent(category: string, action: string, name?: string, value?: string | number): number; + trackPageView(title?: string): void; + setCustomDimension(id: number, value: string): void; + + // State and status methods + getState(): MatomoState & MatomoStatus; + getStatus(): MatomoStatus; + isMatomoLoaded(): boolean; + getMatomoCookies(): string[]; + deleteMatomoCookies(): Promise; + + // Consent dialog logic + shouldShowConsentDialog(configApi?: any): boolean; + + // Script loading + loadScript(): Promise; + waitForLoad(timeout?: number): Promise; + + // Plugin loading + loadPlugin(src: string, options?: PluginLoadOptions): Promise; + loadDebugPlugin(): Promise; + loadDebugPluginForE2E(): Promise; + getLoadedPlugins(): string[]; + isPluginLoaded(src: string): boolean; + + // Queue management + getPreInitQueue(): MatomoCommand[]; + getQueueStatus(): { queueLength: number; initialized: boolean; commands: MatomoCommand[] }; + processPreInitQueue(): Promise; + clearPreInitQueue(): number; + + // Utility and diagnostic methods + testConsentBehavior(): Promise; + getDiagnostics(): MatomoDiagnostics; + + // Bot detection methods + getBotDetectionResult(): BotDetectionResult | null; + isBot(): boolean; + getBotType(): string; + getBotConfidence(): 'high' | 'medium' | 'low' | null; + inspectPaqArray(): { length: number; contents: any[]; trackingCommands: any[] }; + batch(commands: MatomoCommand[]): void; + reset(): Promise; + + // Event system + on(event: string, callback: EventListener): void; + off(event: string, callback: EventListener): void; +} + +// ================== MAIN CLASS ================== + +export class MatomoManager implements IMatomoManager { + private readonly config: Required; + private state: MatomoState; + private readonly eventQueue: MatomoCommand[]; + private readonly listeners: Map; + private readonly preInitQueue: MatomoCommand[] = []; + private readonly loadedPlugins: Set = new Set(); + private originalPaqPush: ((...args: any[]) => void) | null = null; + private customDimensions: DomainCustomDimensions; + private botDetectionResult: BotDetectionResult | null = null; + + constructor(config: MatomoConfig) { + this.config = { + debug: false, + customDimensions: {}, + onStateChange: null, + logPrefix: '[MATOMO]', + scriptTimeout: 10000, + retryAttempts: 3, + matomoDomains: {}, + siteId: 0, // Default fallback, will be derived if not explicitly set + mouseTrackingDelay: 2000, // Wait 2 seconds for mouse movements + waitForMouseTracking: true, // Enable mouse tracking delay by default + ...config + }; + + this.state = { + initialized: false, + scriptLoaded: false, + currentMode: null, + consentGiven: false, + lastEventId: 0, + loadingPromise: null + }; + + this.eventQueue = []; + this.listeners = new Map(); + + // Derive siteId from matomoDomains if not explicitly provided or is default + // (moved after listeners initialization so logging works) + if (!config.siteId || config.siteId === 0) { + this.config.siteId = this.deriveSiteId(); + } + + // Initialize domain-specific custom dimensions + this.customDimensions = getDomainCustomDimensions(); + + // Start mouse tracking immediately (but don't analyze yet) + if (this.config.waitForMouseTracking) { + BotDetector.startMouseTracking(); + this.log('Mouse tracking started - will analyze before initialization'); + } + + // Perform initial bot detection (without mouse data) + this.botDetectionResult = BotDetector.detect(false); // Don't include mouse tracking yet + this.log('Initial bot detection result (without mouse):', this.botDetectionResult); + + this.setupPaqInterception(); + this.log('MatomoManager initialized', this.config); + this.log('Custom dimensions for domain:', this.customDimensions); + } + + // ================== SITE ID DERIVATION ================== + + /** + * Derive siteId from matomoDomains based on current hostname + * Falls back to electron detection or returns 0 if no match + */ + private deriveSiteId(): number { + const hostname = window.location.hostname; + const domains = this.config.matomoDomains || {}; + + // Check if current hostname has a matching site ID + if (domains[hostname]) { + this.log(`Derived siteId ${domains[hostname]} from hostname: ${hostname}`); + return domains[hostname]; + } + + // Check for electron environment + const isElectron = (window as any).electronAPI !== undefined; + if (isElectron && domains['localhost']) { + this.log(`Derived siteId ${domains['localhost']} for electron environment`); + return domains['localhost']; + } + + this.log(`No siteId found for hostname: ${hostname}, using fallback: 0`); + return 0; + } + + // ================== LOGGING & DEBUGGING ================== + + private log(message: string, data?: any): void { + if (!this.config.debug) return; + + const timestamp = new Date().toLocaleTimeString(); + const fullMessage = `${this.config.logPrefix} [${timestamp}] ${message}`; + + if (data) { + console.log(fullMessage, data); + } else { + console.log(fullMessage); + } + + this.emit('log', { message, data, timestamp }); + } + + private setupPaqInterception(): void { + this.log('Setting up _paq interception'); + if (typeof window === 'undefined') return; + + window._paq = window._paq || []; + + // Check for any existing tracking events and queue them + const existingEvents = window._paq.filter(cmd => this.isTrackingCommand(cmd)); + if (existingEvents.length > 0) { + this.log(`🟡 Found ${existingEvents.length} existing tracking events, moving to queue`); + existingEvents.forEach(cmd => { + this.preInitQueue.push(cmd as MatomoCommand); + }); + + // Remove tracking events from _paq, keep only config events + window._paq = window._paq.filter(cmd => !this.isTrackingCommand(cmd)); + this.log(`📋 Cleaned _paq array: ${window._paq.length} config commands remaining`); + } + + // Store original push for later restoration + this.originalPaqPush = Array.prototype.push; + const self = this; + + window._paq.push = function(...args: MatomoCommand[]): number { + // Process each argument + const commandsToQueue: MatomoCommand[] = []; + const commandsToPush: MatomoCommand[] = []; + + args.forEach((arg, index) => { + if (Array.isArray(arg)) { + self.log(`_paq.push[${index}]: [${arg.map(item => + typeof item === 'string' ? `"${item}"` : item + ).join(', ')}]`); + } else { + self.log(`_paq.push[${index}]: ${JSON.stringify(arg)}`); + } + + // Queue tracking events if not initialized yet + if (!self.state.initialized && self.isTrackingCommand(arg)) { + self.log(`🟡 QUEUING pre-init tracking command: ${JSON.stringify(arg)}`); + self.preInitQueue.push(arg as MatomoCommand); + commandsToQueue.push(arg as MatomoCommand); + self.emit('command-queued', arg); + // DO NOT add to commandsToPush - this prevents it from reaching _paq + } else { + // Either not a tracking command or we're initialized + commandsToPush.push(arg as MatomoCommand); + } + }); + + // Only push non-queued commands to _paq + if (commandsToPush.length > 0) { + self.emit('paq-command', commandsToPush); + const result = self.originalPaqPush!.apply(this, commandsToPush); + self.log(`📋 Added ${commandsToPush.length} commands to _paq (length now: ${this.length})`); + return result; + } + + // If we only queued commands, don't modify _paq at all + if (commandsToQueue.length > 0) { + self.log(`📋 Queued ${commandsToQueue.length} commands, _paq unchanged (length: ${this.length})`); + } + + // Return current length (unchanged) + return this.length; + }; + } + + /** + * Check if a command is a tracking command that should be queued + */ + private isTrackingCommand(command: any): boolean { + if (!Array.isArray(command) || command.length === 0) return false; + + const trackingCommands = [ + 'trackEvent', + 'trackPageView', + 'trackSiteSearch', + 'trackGoal', + 'trackLink', + 'trackDownload' + ]; + + return trackingCommands.includes(command[0]); + } + + // ================== INITIALIZATION PATTERNS ================== + + /** + * Initialize Matomo with different patterns + */ + async initialize(pattern: InitializationPattern = 'cookie-consent', options: InitializationOptions = {}): Promise { + if (this.state.initialized) { + this.log('Already initialized, skipping'); + return; + } + + // For localhost/127.0.0.1, only initialize Matomo when explicitly requested + // This prevents CircleCI tests from flooding the localhost Matomo domain + const isLocalhost = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); + if (isLocalhost) { + // Check developer flag first, then localStorage as fallback + const showMatomo = ENABLE_MATOMO_LOCALHOST || (typeof localStorage !== 'undefined' && localStorage.getItem('showMatomo') === 'true'); + if (!showMatomo) { + this.log('Skipping Matomo initialization on localhost - set ENABLE_MATOMO_LOCALHOST=true in MatomoConfig.ts or localStorage.setItem("showMatomo", "true") to enable'); + return; + } + } + + // Prevent multiple simultaneous initializations + if (this.state.loadingPromise) { + this.log('Initialization already in progress, waiting...'); + return this.state.loadingPromise; + } + + this.state.loadingPromise = this.performInitialization(pattern, options); + + try { + await this.state.loadingPromise; + } finally { + this.state.loadingPromise = null; + } + } + + private async performInitialization(pattern: InitializationPattern, options: InitializationOptions): Promise { + this.log(`=== INITIALIZING MATOMO: ${pattern.toUpperCase()} ===`); + this.log(`📋 _paq array before init: ${window._paq.length} commands`); + this.log(`📋 Pre-init queue before init: ${this.preInitQueue.length} commands`); + + // Wait for mouse tracking to gather data + if (this.config.waitForMouseTracking) { + await this.waitForMouseData(); + } + + // Determine site ID based on bot detection + const isBot = this.botDetectionResult?.isBot || false; + const siteId = getSiteIdForTracking(isBot); + + if (siteId !== this.config.siteId) { + this.log(`🤖 Bot detected - routing to bot tracking site ID: ${siteId} (human site ID: ${this.config.siteId})`); + + // Update custom dimensions if bot site has different dimension IDs + const botDimensions = getDomainCustomDimensions(true); + if (botDimensions !== this.customDimensions) { + this.customDimensions = botDimensions; + this.log('🔄 Updated to bot-specific custom dimensions:', botDimensions); + } + } + + // Basic setup + this.log('Setting tracker URL and site ID'); + window._paq.push(['setTrackerUrl', this.config.trackerUrl]); + window._paq.push(['setSiteId', siteId]); // Use bot site ID if configured + + // Apply pattern-specific configuration + await this.applyInitializationPattern(pattern, options); + + // Common setup + this.log('Enabling standard features'); + window._paq.push(['enableJSErrorTracking']); + window._paq.push(['enableLinkTracking']); + + // Set custom dimensions + for (const [id, value] of Object.entries(this.config.customDimensions)) { + this.log(`Setting custom dimension ${id}: ${value}`); + window._paq.push(['setCustomDimension', parseInt(id), value]); + } + + // Set bot detection dimension + if (this.botDetectionResult) { + const botTypeValue = this.botDetectionResult.isBot + ? this.botDetectionResult.botType || 'unknown-bot' + : 'human'; + this.log(`Setting bot detection dimension ${this.customDimensions.isBot}: ${botTypeValue} (confidence: ${this.botDetectionResult.confidence})`); + window._paq.push(['setCustomDimension', this.customDimensions.isBot, botTypeValue]); + + // Log bot detection reasons in debug mode + if (this.botDetectionResult.reasons.length > 0) { + this.log('Bot detection reasons:', this.botDetectionResult.reasons); + } + } + + // Mark as initialized BEFORE adding trackPageView to prevent it from being queued + this.state.initialized = true; + this.state.currentMode = pattern; + + // Set E2E marker for bot detection completion + this.setE2EStateMarker('matomo-bot-detection-complete'); + + // Set trackingMode dimension before bot detection event based on pattern + // This ensures the bot detection event has proper tracking mode metadata + if (pattern === 'anonymous') { + window._paq.push(['setCustomDimension', this.customDimensions.trackingMode, 'anon']); + this.log('Set trackingMode dimension: anon'); + } else if (pattern === 'immediate') { + window._paq.push(['setCustomDimension', this.customDimensions.trackingMode, 'cookie']); + this.log('Set trackingMode dimension: cookie (immediate consent)'); + } else if (pattern === 'cookie-consent') { + // For cookie-consent mode, we'll set dimension to 'cookie' after consent is given + // For now, set to 'pending' to indicate consent not yet given + window._paq.push(['setCustomDimension', this.customDimensions.trackingMode, 'pending']); + this.log('Set trackingMode dimension: pending (awaiting consent)'); + } + // no-consent mode doesn't set dimension explicitly + + // Send bot detection event to Matomo for analytics + if (this.botDetectionResult) { + this.trackBotDetectionEvent(this.botDetectionResult); + } + + // Initial page view (now that we're initialized, this won't be queued) + this.log('Sending initial page view'); + window._paq.push(['trackPageView']); + + this.log(`📋 _paq array before script load: ${window._paq.length} commands`); + + // Load script + await this.loadScript(); + + this.log(`=== INITIALIZATION COMPLETE: ${pattern} ===`); + this.log(`📋 _paq array after init: ${window._paq.length} commands`); + this.log(`📋 Pre-init queue contains ${this.preInitQueue.length} commands (use processPreInitQueue() to flush)`); + + // Set E2E marker for complete initialization + this.setE2EStateMarker('matomo-initialized'); + + this.emit('initialized', { pattern, options }); + } + + /** + * Track bot detection result as a Matomo event + * This sends detection details to Matomo for analysis + */ + private trackBotDetectionEvent(detection: BotDetectionResult): void { + const category = 'bot-detection'; + const action = detection.isBot ? 'bot-detected' : 'human-detected'; + + // Name: Primary detection reason (most important one) + let name = ''; + if (detection.isBot && detection.reasons.length > 0) { + // Extract the key detection method from first reason + const firstReason = detection.reasons[0]; + if (firstReason.includes('navigator.webdriver')) { + name = 'webdriver-flag'; + } else if (firstReason.includes('User agent')) { + name = 'user-agent-pattern'; + } else if (firstReason.includes('headless')) { + name = 'headless-browser'; + } else if (firstReason.includes('Browser automation')) { + name = 'automation-detected'; + } else if (firstReason.includes('missing features')) { + name = 'missing-features'; + } else if (firstReason.includes('Behavioral signals')) { + name = 'behavioral-signals'; + } else if (firstReason.includes('Mouse')) { + name = 'mouse-patterns'; + } else { + name = 'other-detection'; + } + } else if (!detection.isBot) { + // For humans, indicate detection method + if (detection.mouseAnalysis?.humanLikelihood === 'high') { + name = 'human-mouse-confirmed'; + } else if (detection.mouseAnalysis?.humanLikelihood === 'medium') { + name = 'human-mouse-likely'; + } else { + name = 'human-no-bot-signals'; + } + } + + // Value: encode detection confidence + number of detection signals + // High confidence = 100, Medium = 50, Low = 10 + // Add number of reasons as bonus (capped at 9) + const baseConfidence = detection.confidence === 'high' ? 100 : + detection.confidence === 'medium' ? 50 : 10; + const reasonCount = Math.min(detection.reasons.length, 9); + const value = baseConfidence + reasonCount; + + // Track the event + window._paq.push([ + 'trackEvent', + category, + action, + name, + value + ]); + + this.log(`📊 Bot detection event tracked: ${action} → ${name} (confidence: ${detection.confidence}, reasons: ${detection.reasons.length}, value: ${value})`); + + // Log all reasons for debugging + if (this.config.debug && detection.reasons.length > 0) { + this.log(` Detection reasons:`); + detection.reasons.forEach((reason, i) => { + this.log(` ${i + 1}. ${reason}`); + }); + } + + // Log mouse analysis if available + if (detection.mouseAnalysis) { + this.log(` Mouse: ${detection.mouseAnalysis.movements} movements, likelihood: ${detection.mouseAnalysis.humanLikelihood}`); + } + } + + /** + * Wait for mouse tracking data before initializing Matomo + * This ensures we have accurate human/bot detection before sending any events + */ + private async waitForMouseData(): Promise { + const delay = this.config.mouseTrackingDelay || 2000; + this.log(`âŗ Waiting ${delay}ms for mouse movements to determine human/bot status...`); + + // Wait for the configured delay + await new Promise(resolve => setTimeout(resolve, delay)); + + // Re-run bot detection with mouse tracking data + this.botDetectionResult = BotDetector.detect(true); // Include mouse analysis + this.log('✅ Bot detection complete with mouse data:', this.botDetectionResult); + + if (this.botDetectionResult.mouseAnalysis) { + this.log('đŸ–ąī¸ Mouse analysis:', { + movements: this.botDetectionResult.mouseAnalysis.movements, + humanLikelihood: this.botDetectionResult.mouseAnalysis.humanLikelihood, + suspiciousPatterns: this.botDetectionResult.mouseAnalysis.suspiciousPatterns + }); + } + } + + private async applyInitializationPattern(pattern: InitializationPattern, options: InitializationOptions): Promise { + switch (pattern) { + case 'cookie-consent': + await this.initializeCookieConsent(options); + break; + case 'anonymous': + await this.initializeAnonymous(options); + break; + case 'immediate': + await this.initializeImmediate(options); + break; + case 'no-consent': + await this.initializeNoConsent(options); + break; + default: + throw new Error(`Unknown initialization pattern: ${pattern}`); + } + } + + private async initializeCookieConsent(options: InitializationOptions = {}): Promise { + this.log('Pattern: Cookie consent required'); + window._paq.push(['requireCookieConsent']); + this.state.consentGiven = false; + } + + private async initializeAnonymous(options: InitializationOptions = {}): Promise { + this.log('Pattern: Anonymous mode (no cookies)'); + window._paq.push(['disableCookies']); + window._paq.push(['disableBrowserFeatureDetection']); + if (options.trackingMode !== false) { + window._paq.push(['setCustomDimension', this.customDimensions.trackingMode, 'anon']); + } + } + + private async initializeImmediate(options: InitializationOptions = {}): Promise { + this.log('Pattern: Immediate consent (cookies enabled)'); + window._paq.push(['requireCookieConsent']); + window._paq.push(['rememberConsentGiven']); + if (options.trackingMode !== false) { + window._paq.push(['setCustomDimension', this.customDimensions.trackingMode, 'cookie']); + } + this.state.consentGiven = true; + } + + private async initializeNoConsent(options: InitializationOptions = {}): Promise { + this.log('Pattern: No consent management (cookies auto-enabled)'); + // No consent calls - Matomo will create cookies automatically + } + + // ================== MODE SWITCHING ================== + + /** + * Switch between tracking modes + */ + async switchMode(mode: TrackingMode, options: ModeSwitchOptions & { processQueue?: boolean } = {}): Promise { + if (!this.state.initialized) { + throw new Error('MatomoManager must be initialized before switching modes'); + } + + this.log(`=== SWITCHING TO ${mode.toUpperCase()} MODE ===`); + + const wasMatomoLoaded = this.isMatomoLoaded(); + this.log(`Matomo loaded: ${wasMatomoLoaded}`); + + try { + switch (mode) { + case 'cookie': + await this.switchToCookieMode(wasMatomoLoaded, options); + break; + case 'anonymous': + await this.switchToAnonymousMode(wasMatomoLoaded, options); + break; + default: + throw new Error(`Unknown mode: ${mode}`); + } + + this.state.currentMode = mode as InitializationPattern; + this.log(`=== MODE SWITCH COMPLETE: ${mode} ===`); + + // Auto-process queue when switching modes (final decision) + if (options.processQueue !== false && this.preInitQueue.length > 0) { + this.log(`🔄 Auto-processing queue after mode switch to ${mode}`); + await this.flushPreInitQueue(); + } + + this.emit('mode-switched', { mode, options, wasMatomoLoaded }); + } catch (error) { + this.log(`Error switching to ${mode} mode:`, error); + this.emit('mode-switch-error', { mode, options, error }); + throw error; + } + } + + private async switchToCookieMode(wasMatomoLoaded: boolean, options: ModeSwitchOptions): Promise { + if (!wasMatomoLoaded) { + this.log('Matomo not loaded - queuing cookie mode setup'); + window._paq.push(['requireCookieConsent']); + } else { + this.log('Matomo loaded - applying cookie mode immediately'); + window._paq.push(['requireCookieConsent']); + } + + window._paq.push(['rememberConsentGiven']); + window._paq.push(['enableBrowserFeatureDetection']); + + if (options.setDimension !== false) { + window._paq.push(['setCustomDimension', this.customDimensions.trackingMode, 'cookie']); + } + + window._paq.push(['trackEvent', 'mode_switch', 'cookie_mode', 'enabled']); + this.state.consentGiven = true; + } + + private async switchToAnonymousMode(wasMatomoLoaded: boolean, options: ModeSwitchOptions): Promise { + if (options.forgetConsent && wasMatomoLoaded) { + this.log('WARNING: Using forgetCookieConsentGiven on loaded Matomo may break tracking'); + window._paq.push(['forgetCookieConsentGiven']); + } + + // BUG FIX: Always set consentGiven to false when switching to anonymous mode + // Anonymous mode means no cookies, which means no consent for cookie tracking + this.state.consentGiven = false; + this.log('Consent state set to false (anonymous mode = no cookie consent)'); + + if (options.deleteCookies !== false) { + await this.deleteMatomoCookies(); + } + + window._paq.push(['disableCookies']); + window._paq.push(['disableBrowserFeatureDetection']); + + if (options.setDimension !== false) { + window._paq.push(['setCustomDimension', this.customDimensions.trackingMode, 'anon']); + } + + window._paq.push(['trackEvent', 'mode_switch', 'anonymous_mode', 'enabled']); + } + + // ================== CONSENT MANAGEMENT ================== + + async giveConsent(options: { processQueue?: boolean } = {}): Promise { + this.log('=== GIVING CONSENT ==='); + window._paq.push(['rememberConsentGiven']); + + // Update trackingMode dimension from 'pending' to 'cookie' when consent given + window._paq.push(['setCustomDimension', this.customDimensions.trackingMode, 'cookie']); + this.log('Updated trackingMode dimension: cookie (consent given)'); + + this.state.consentGiven = true; + this.emit('consent-given'); + + // Automatically process queue when giving consent (final decision) + if (options.processQueue !== false && this.preInitQueue.length > 0) { + this.log('🔄 Auto-processing queue after consent given'); + await this.flushPreInitQueue(); + } + } + + async revokeConsent(): Promise { + this.log('=== REVOKING CONSENT ==='); + this.log('WARNING: This will stop tracking until consent is given again'); + window._paq.push(['forgetCookieConsentGiven']); + this.state.consentGiven = false; + this.emit('consent-revoked'); + + // Don't process queue when revoking - user doesn't want tracking + if (this.preInitQueue.length > 0) { + this.log(`📋 Queue contains ${this.preInitQueue.length} commands (not processed due to consent revocation)`); + } + } + + // ================== TRACKING METHODS ================== + + // Support both type-safe MatomoEvent objects and legacy signatures temporarily + trackEvent(event: MatomoEvent): number; + trackEvent(category: string, action: string, name?: string, value?: string | number): number; + trackEvent(eventObjOrCategory: MatomoEvent | string, action?: string, name?: string, value?: string | number): number { + const eventId = ++this.state.lastEventId; + + // If first parameter is a MatomoEvent object, use type-safe approach + if (typeof eventObjOrCategory === 'object' && eventObjOrCategory !== null && 'category' in eventObjOrCategory) { + const { category, action: eventAction, name: eventName, value: eventValue, isClick } = eventObjOrCategory; + this.log(`Tracking type-safe event ${eventId}: ${category} / ${eventAction} / ${eventName} / ${eventValue} / isClick: ${isClick}`); + + // Set custom action dimension for click tracking + if (isClick !== undefined) { + window._paq.push(['setCustomDimension', this.customDimensions.clickAction, isClick ? 'true' : 'false']); + } + + const matomoEvent: MatomoCommand = ['trackEvent', category, eventAction]; + if (eventName !== undefined) matomoEvent.push(eventName); + if (eventValue !== undefined) matomoEvent.push(eventValue); + + window._paq.push(matomoEvent); + this.emit('event-tracked', { eventId, category, action: eventAction, name: eventName, value: eventValue, isClick }); + + return eventId; + } + + // Legacy string-based approach - no isClick dimension set + const category = eventObjOrCategory as string; + this.log(`Tracking legacy event ${eventId}: ${category} / ${action} / ${name} / ${value} (âš ī¸ no click dimension)`); + + const matomoEvent: MatomoCommand = ['trackEvent', category, action!]; + if (name !== undefined) matomoEvent.push(name); + if (value !== undefined) matomoEvent.push(value); + + window._paq.push(matomoEvent); + this.emit('event-tracked', { eventId, category, action, name, value }); + + return eventId; + } + + trackPageView(title?: string): void { + this.log(`Tracking page view: ${title || 'default'}`); + const pageView: MatomoCommand = ['trackPageView']; + if (title) pageView.push(title); + + window._paq.push(pageView); + this.emit('page-view-tracked', { title }); + } + + setCustomDimension(id: number, value: string): void { + this.log(`Setting custom dimension ${id}: ${value}`); + window._paq.push(['setCustomDimension', id, value]); + this.emit('custom-dimension-set', { id, value }); + } + + // ================== STATE MANAGEMENT ================== + + getState(): MatomoState & MatomoStatus { + return { + ...this.state, + ...this.getStatus() + }; + } + + getStatus(): MatomoStatus { + return { + matomoLoaded: this.isMatomoLoaded(), + paqLength: window._paq ? window._paq.length : 0, + paqType: window._paq ? (Array.isArray(window._paq) ? 'array' : 'object') : 'undefined', + cookieCount: this.getMatomoCookies().length, + cookies: this.getMatomoCookies() + }; + } + + isMatomoLoaded(): boolean { + return typeof window !== 'undefined' && + (typeof window.Matomo !== 'undefined' || typeof window.Piwik !== 'undefined'); + } + + getMatomoCookies(): string[] { + if (typeof document === 'undefined') return []; + + try { + return document.cookie + .split(';') + .map(cookie => cookie.trim()) + .filter(cookie => cookie.startsWith('_pk_') || cookie.startsWith('mtm_')); + } catch (e) { + return []; + } + } + + async deleteMatomoCookies(): Promise { + if (typeof document === 'undefined') return; + + this.log('Deleting Matomo cookies'); + const cookies = document.cookie.split(';'); + + const deletionPromises: Promise[] = []; + + for (const cookie of cookies) { + const eqPos = cookie.indexOf('='); + const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); + + if (name.startsWith('_pk_') || name.startsWith('mtm_')) { + // Delete for multiple domain/path combinations + const deletions = [ + `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`, + `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${window.location.hostname}`, + `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${window.location.hostname}` + ]; + + deletions.forEach(deletion => { + document.cookie = deletion; + }); + + this.log(`Deleted cookie: ${name}`); + + // Add a small delay to ensure cookie deletion is processed + deletionPromises.push(new Promise(resolve => setTimeout(resolve, 10))); + } + } + + await Promise.all(deletionPromises); + } + + // ================== SCRIPT LOADING ================== + + async loadScript(): Promise { + if (this.state.scriptLoaded) { + this.log('Script already loaded'); + return; + } + + if (typeof document === 'undefined') { + throw new Error('Cannot load script: document is not available'); + } + + const existingScript = document.querySelector('script[src*="matomo.js"]'); + if (existingScript) { + this.log('Script element already exists'); + this.state.scriptLoaded = true; + return; + } + + return this.loadScriptWithRetry(); + } + + private async loadScriptWithRetry(attempt: number = 1): Promise { + try { + await this.doLoadScript(); + } catch (error) { + if (attempt < this.config.retryAttempts) { + this.log(`Script loading failed (attempt ${attempt}), retrying...`, error); + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + return this.loadScriptWithRetry(attempt + 1); + } else { + this.log('Script loading failed after all retries', error); + throw error; + } + } + } + + private async doLoadScript(): Promise { + return new Promise((resolve, reject) => { + this.log('Loading Matomo script'); + const script = document.createElement('script'); + script.async = true; + script.src = this.config.trackerUrl.replace('/matomo.php', '/matomo.js'); + + const timeout = setTimeout(() => { + script.remove(); + reject(new Error(`Script loading timeout after ${this.config.scriptTimeout}ms`)); + }, this.config.scriptTimeout); + + script.onload = () => { + clearTimeout(timeout); + this.log('Matomo script loaded successfully'); + this.state.scriptLoaded = true; + this.emit('script-loaded'); + resolve(); + }; + + script.onerror = (error) => { + clearTimeout(timeout); + script.remove(); + this.log('Failed to load Matomo script', error); + this.emit('script-error', error); + reject(new Error('Failed to load Matomo script')); + }; + + document.head.appendChild(script); + }); + } + + // ================== PLUGIN LOADING ================== + + /** + * Load a Matomo plugin script + */ + async loadPlugin(src: string, options: PluginLoadOptions = {}): Promise { + const { + timeout = this.config.scriptTimeout, + retryAttempts = this.config.retryAttempts, + onLoad, + onError, + initFunction + } = options; + + // Check if plugin is already loaded + if (this.loadedPlugins.has(src)) { + this.log(`Plugin already loaded: ${src}`); + return; + } + + if (typeof document === 'undefined') { + throw new Error('Cannot load plugin: document is not available'); + } + + // Check if script element already exists + const existingScript = document.querySelector(`script[src="${src}"]`); + if (existingScript) { + this.log(`Plugin script already exists: ${src}`); + this.loadedPlugins.add(src); + return; + } + + return this.loadPluginWithRetry(src, options, 1); + } + + /** + * Load the Matomo debug plugin specifically + */ + async loadDebugPlugin(): Promise { + const src = 'assets/js/matomo-debug-plugin.js'; + + return this.loadPlugin(src, { + initFunction: 'initMatomoDebugPlugin', + onLoad: () => { + this.log('Debug plugin loaded and initialized'); + this.emit('debug-plugin-loaded'); + }, + onError: (error) => { + this.log('Debug plugin failed to load:', error); + this.emit('debug-plugin-error', error); + } + }); + } + + /** + * Load debug plugin specifically for E2E testing with enhanced helpers + * Returns easy-to-use helper functions for test assertions + */ + async loadDebugPluginForE2E(): Promise { + await this.loadDebugPlugin(); + + // Wait a bit for plugin to be fully registered + await new Promise(resolve => setTimeout(resolve, 100)); + + const helpers: DebugPluginE2EHelpers = { + getEvents: () => (window as any).__getMatomoEvents?.() || [], + getLatestEvent: () => (window as any).__getLatestMatomoEvent?.() || null, + getEventsByCategory: (category: string) => (window as any).__getMatomoEventsByCategory?.(category) || [], + getEventsByAction: (action: string) => (window as any).__getMatomoEventsByAction?.(action) || [], + getPageViews: () => (window as any).__getMatomoPageViews?.() || [], + getVisitorIds: () => (window as any).__getLatestVisitorId?.() || null, + getDimensions: () => (window as any).__getMatomoDimensions?.() || {}, + clearData: () => (window as any).__clearMatomoDebugData?.(), + + waitForEvent: async (category?: string, action?: string, timeout = 5000): Promise => { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const checkForEvent = () => { + const events = helpers.getEvents(); + + let matchingEvent = null; + if (category && action) { + matchingEvent = events.find(e => e.category === category && e.action === action); + } else if (category) { + matchingEvent = events.find(e => e.category === category); + } else if (action) { + matchingEvent = events.find(e => e.action === action); + } else { + matchingEvent = events[events.length - 1]; // Latest event + } + + if (matchingEvent) { + resolve(matchingEvent); + return; + } + + if (Date.now() - startTime > timeout) { + reject(new Error(`Timeout waiting for event${category ? ` category=${category}` : ''}${action ? ` action=${action}` : ''}`)); + return; + } + + setTimeout(checkForEvent, 100); + }; + + checkForEvent(); + }); + } + }; + + this.log('Debug plugin loaded for E2E testing with enhanced helpers'); + + // Set E2E marker for debug plugin loaded + this.setE2EStateMarker('matomo-debug-plugin-loaded'); + + this.emit('debug-plugin-e2e-ready', helpers); + + return helpers; + } + + /** + * Get list of loaded plugins + */ + getLoadedPlugins(): string[] { + return Array.from(this.loadedPlugins); + } + + /** + * Check if a specific plugin is loaded + */ + isPluginLoaded(src: string): boolean { + return this.loadedPlugins.has(src); + } + + private async loadPluginWithRetry(src: string, options: PluginLoadOptions, attempt: number): Promise { + const retryAttempts = options.retryAttempts || this.config.retryAttempts; + + try { + await this.doLoadPlugin(src, options); + this.loadedPlugins.add(src); + } catch (error) { + if (attempt < retryAttempts) { + this.log(`Plugin loading failed (attempt ${attempt}), retrying...`, error); + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + return this.loadPluginWithRetry(src, options, attempt + 1); + } else { + this.log(`Plugin loading failed after all retries: ${src}`, error); + if (options.onError) { + options.onError(error instanceof Error ? error : new Error(String(error))); + } + throw error; + } + } + } + + private async doLoadPlugin(src: string, options: PluginLoadOptions): Promise { + const timeout = options.timeout || this.config.scriptTimeout; + + return new Promise((resolve, reject) => { + this.log(`Loading plugin: ${src}`); + + const script = document.createElement('script'); + script.async = true; + script.src = src; + + const timeoutId = setTimeout(() => { + script.remove(); + reject(new Error(`Plugin loading timeout after ${timeout}ms: ${src}`)); + }, timeout); + + script.onload = () => { + clearTimeout(timeoutId); + this.log(`Plugin script loaded: ${src}`); + + // Call initialization function if specified + if (options.initFunction && typeof (window as any)[options.initFunction] === 'function') { + try { + (window as any)[options.initFunction](); + this.log(`Plugin initialized: ${options.initFunction}`); + } catch (initError) { + this.log(`Plugin initialization failed: ${options.initFunction}`, initError); + } + } + + if (options.onLoad) { + options.onLoad(); + } + + this.emit('plugin-loaded', { src, options }); + resolve(); + }; + + script.onerror = (error) => { + clearTimeout(timeoutId); + script.remove(); + const errorMessage = `Failed to load plugin: ${src}`; + this.log(errorMessage, error); + const pluginError = new Error(errorMessage); + + if (options.onError) { + options.onError(pluginError); + } + + this.emit('plugin-error', { src, error: pluginError }); + reject(pluginError); + }; + + document.head.appendChild(script); + }); + } + + // ================== RESET & CLEANUP ================== + + async reset(): Promise { + this.log('=== RESETTING MATOMO ==='); + + // Delete cookies + await this.deleteMatomoCookies(); + + // Clear pre-init queue + const queuedCommands = this.clearPreInitQueue(); + + // Clear _paq array + if (window._paq && Array.isArray(window._paq)) { + window._paq.length = 0; + this.log('_paq array cleared'); + } + + // Remove scripts + if (typeof document !== 'undefined') { + const scripts = document.querySelectorAll('script[src*="matomo.js"]'); + scripts.forEach(script => { + script.remove(); + this.log('Matomo script removed'); + }); + } + + // Reset state + this.state = { + initialized: false, + scriptLoaded: false, + currentMode: null, + consentGiven: false, + lastEventId: 0, + loadingPromise: null + }; + + this.log(`=== RESET COMPLETE (cleared ${queuedCommands} queued commands) ===`); + this.emit('reset'); + } + + // ================== EVENT SYSTEM ================== + + on(event: string, callback: EventListener): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(callback); + } + + off(event: string, callback: EventListener): void { + if (this.listeners.has(event)) { + const callbacks = this.listeners.get(event)!; + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + } + + private emit(event: string, data: any = null): void { + if (this.listeners.has(event)) { + this.listeners.get(event)!.forEach(callback => { + try { + callback(data); + } catch (error) { + console.error(`Error in ${event} listener:`, error); + } + }); + } + + // Call global state change handler if configured + if (this.config.onStateChange && + ['initialized', 'mode-switched', 'consent-given', 'consent-revoked'].includes(event)) { + try { + this.config.onStateChange(event, data, this.getState()); + } catch (error) { + console.error('Error in onStateChange handler:', error); + } + } + } + + // ================== UTILITY METHODS ================== + + /** + * Test a specific consent behavior + */ + async testConsentBehavior(): Promise { + this.log('=== TESTING CONSENT BEHAVIOR ==='); + + const cookiesBefore = this.getMatomoCookies(); + this.log('Cookies before requireCookieConsent:', cookiesBefore); + + window._paq.push(['requireCookieConsent']); + + // Check immediately and after delay + const cookiesImmediate = this.getMatomoCookies(); + this.log('Cookies immediately after requireCookieConsent:', cookiesImmediate); + + return new Promise((resolve) => { + setTimeout(() => { + const cookiesAfter = this.getMatomoCookies(); + this.log('Cookies 2 seconds after requireCookieConsent:', cookiesAfter); + + if (cookiesBefore.length > 0 && cookiesAfter.length === 0) { + this.log('🚨 CONFIRMED: requireCookieConsent DELETED existing cookies!'); + } else if (cookiesBefore.length === cookiesAfter.length) { + this.log('✅ requireCookieConsent did NOT delete existing cookies'); + } + + resolve(); + }, 2000); + }); + } + + /** + * Get detailed diagnostic information + */ + getDiagnostics(): MatomoDiagnostics { + const state = this.getState(); + let tracker: { url: string; siteId: number | string } | null = null; + + if (this.isMatomoLoaded() && window.Matomo) { + try { + const matomoTracker = window.Matomo.getTracker(); + tracker = { + url: matomoTracker.getTrackerUrl(), + siteId: matomoTracker.getSiteId(), + }; + } catch (error) { + this.log('Error getting tracker info:', error); + } + } + + return { + config: this.config, + state, + status: this.getStatus(), + tracker, + plugins: this.getLoadedPlugins(), + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent.substring(0, 100) : 'N/A', + timestamp: new Date().toISOString() + }; + } + + /** + * Determines whether the Matomo consent dialog should be shown + * Based on existing configuration and consent expiration + */ + shouldShowConsentDialog(configApi?: any): boolean { + try { + // Use domains from constructor config or fallback to empty object + const matomoDomains = this.config.matomoDomains || {}; + + const isElectron = (window as any).electronAPI !== undefined; + const isSupported = matomoDomains[window.location.hostname] || isElectron; + + if (!isSupported) { + return false; + } + + // For localhost/127.0.0.1, only enable Matomo when explicitly requested + // This prevents CircleCI tests from flooding the localhost Matomo domain + const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + if (isLocalhost) { + // Check developer flag first, then localStorage as fallback + const showMatomo = ENABLE_MATOMO_LOCALHOST || localStorage.getItem('showMatomo') === 'true'; + if (!showMatomo) { + return false; + } + } + + // Check current configuration + if (!configApi) { + return true; // No config API means we need to show dialog + } + + const hasExistingConfig = configApi.exists('settings/matomo-perf-analytics'); + const currentSetting = configApi.get('settings/matomo-perf-analytics'); + + // If no existing config, show dialog + if (!hasExistingConfig) { + return true; + } + + // Check if consent has expired (6 months) + const lastConsentCheck = window.localStorage.getItem('matomo-analytics-consent'); + if (!lastConsentCheck) { + return true; // No consent timestamp means we need to ask + } + + const consentDate = new Date(Number(lastConsentCheck)); + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + + const consentExpired = consentDate < sixMonthsAgo; + + // Only renew consent if user had disabled analytics and consent has expired + return currentSetting === false && consentExpired; + + } catch (error) { + this.log('Error in shouldShowConsentDialog:', error); + return false; // Fail safely + } + } + + /** + * Get current pre-initialization queue + */ + getPreInitQueue(): MatomoCommand[] { + return [...this.preInitQueue]; + } + + /** + * Get queue status + */ + getQueueStatus(): { + queueLength: number; + initialized: boolean; + commands: MatomoCommand[]; + } { + return { + queueLength: this.preInitQueue.length, + initialized: this.state.initialized, + commands: [...this.preInitQueue] + }; + } + + /** + * Process the pre-init queue manually + * Call this when you've made a final decision about consent/mode + */ + async processPreInitQueue(): Promise { + if (!this.state.initialized) { + throw new Error('Cannot process queue before initialization'); + } + return this.flushPreInitQueue(); + } + + /** + * Execute a queued command using the appropriate MatomoManager method + */ + private executeQueuedCommand(command: MatomoCommand): void { + const [commandName, ...args] = command; + + switch (commandName) { + case 'trackEvent': { + const [category, action, name, value] = args; + this.trackEvent(category, action, name, value); + break; + } + case 'trackPageView': { + const [title] = args; + this.trackPageView(title); + break; + } + case 'setCustomDimension': { + const [id, dimValue] = args; + this.setCustomDimension(id, dimValue); + break; + } + case 'trackSiteSearch': + case 'trackGoal': + case 'trackLink': + case 'trackDownload': + // For other tracking commands, fall back to _paq + this.log(`📋 Using _paq for ${commandName} command: ${JSON.stringify(command)}`); + this.originalPaqPush?.call(window._paq, command); + break; + default: + this.log(`âš ī¸ Unknown queued command: ${commandName}, using _paq fallback`); + this.originalPaqPush?.call(window._paq, command); + break; + } + } + + /** + * Internal method to actually flush the queue + */ + private async flushPreInitQueue(): Promise { + if (this.preInitQueue.length === 0) { + this.log('No pre-init commands to process'); + return; + } + + this.log(`🔄 PROCESSING ${this.preInitQueue.length} QUEUED COMMANDS`); + this.log(`📋 _paq array length before processing: ${window._paq.length}`); + + // Wait a short moment for Matomo to fully initialize + await new Promise(resolve => setTimeout(resolve, 100)); + + // Process each queued command + for (const [index, command] of this.preInitQueue.entries()) { + this.log(`📤 Processing queued command ${index + 1}/${this.preInitQueue.length}: ${JSON.stringify(command)}`); + + // Check current mode and consent state before processing + const currentMode = this.state.currentMode; + const consentGiven = this.state.consentGiven; + + // Skip tracking events if in consent-required mode without consent + if (this.isTrackingCommand(command) && + (currentMode === 'cookie-consent' && !consentGiven)) { + this.log(`đŸšĢ Skipping tracking command in ${currentMode} mode without consent: ${JSON.stringify(command)}`); + continue; + } + + // Use appropriate MatomoManager method instead of bypassing to _paq + this.executeQueuedCommand(command); + + this.log(`📋 _paq length after processing command: ${window._paq.length}`); + + // Small delay between commands to avoid overwhelming + if (index < this.preInitQueue.length - 1) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + this.log(`✅ PROCESSED ALL ${this.preInitQueue.length} QUEUED COMMANDS`); + this.log(`📋 Final _paq array length: ${window._paq.length}`); + this.emit('pre-init-queue-processed', { + commandsProcessed: this.preInitQueue.length, + commands: [...this.preInitQueue] + }); + + // Clear the queue + this.preInitQueue.length = 0; + } + + /** + * Clear the pre-init queue without processing + */ + clearPreInitQueue(): number { + const cleared = this.preInitQueue.length; + this.preInitQueue.length = 0; + this.log(`đŸ—‘ī¸ Cleared ${cleared} queued commands`); + this.emit('pre-init-queue-cleared', { commandsCleared: cleared }); + return cleared; + } + + /** + * Debug method to inspect current _paq contents + */ + inspectPaqArray(): { length: number; contents: any[]; trackingCommands: any[] } { + const contents = [...(window._paq || [])]; + const trackingCommands = contents.filter(cmd => this.isTrackingCommand(cmd)); + + this.log(`🔍 _paq inspection: ${contents.length} total, ${trackingCommands.length} tracking commands`); + contents.forEach((cmd, i) => { + const isTracking = this.isTrackingCommand(cmd); + this.log(` [${i}] ${isTracking ? '📊' : 'âš™ī¸'} ${JSON.stringify(cmd)}`); + }); + + return { + length: contents.length, + contents, + trackingCommands + }; + } + + /** + * Wait for Matomo to be loaded + */ + async waitForLoad(timeout: number = 5000): Promise { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const checkLoaded = () => { + if (this.isMatomoLoaded()) { + resolve(); + } else if (Date.now() - startTime > timeout) { + reject(new Error(`Matomo not loaded after ${timeout}ms`)); + } else { + setTimeout(checkLoaded, 100); + } + }; + + checkLoaded(); + }); + } + + /** + * Batch execute multiple commands + */ + batch(commands: MatomoCommand[]): void { + this.log(`Executing batch of ${commands.length} commands`); + commands.forEach(command => { + window._paq.push(command); + }); + this.emit('batch-executed', { commands }); + } + + // ================== BOT DETECTION METHODS ================== + + /** + * Get full bot detection result with details + */ + getBotDetectionResult(): BotDetectionResult | null { + return this.botDetectionResult; + } + + /** + * Check if current visitor is detected as a bot + */ + isBot(): boolean { + return this.botDetectionResult?.isBot || false; + } + + /** + * Get the type of bot detected (or 'human' if not a bot) + */ + getBotType(): string { + if (!this.botDetectionResult?.isBot) { + return 'human'; + } + return this.botDetectionResult.botType || 'unknown-bot'; + } + + /** + * Get confidence level of bot detection + */ + getBotConfidence(): 'high' | 'medium' | 'low' | null { + return this.botDetectionResult?.confidence || null; + } + + // ================== E2E TESTING HELPERS ================== + + /** + * Set E2E state marker on DOM for reliable test assertions + * Similar to 'compilerloaded' pattern - creates empty div with data-id + * + * @param markerId - Unique identifier for the state (e.g., 'matomo-initialized') + */ + private setE2EStateMarker(markerId: string): void { + // Remove any existing marker with this ID + const existing = document.querySelector(`[data-id="${markerId}"]`); + if (existing) { + existing.remove(); + } + + // Create new marker element + const marker = document.createElement('div'); + marker.setAttribute('data-id', markerId); + marker.style.display = 'none'; + document.body.appendChild(marker); + + this.log(`đŸ§Ē E2E marker set: ${markerId}`); + } +} + +// Default export for convenience +export default MatomoManager; \ No newline at end of file diff --git a/apps/remix-ide/src/app/plugins/compile-details.tsx b/apps/remix-ide/src/app/plugins/compile-details.tsx index 1be931f0ed0..5f053599096 100644 --- a/apps/remix-ide/src/app/plugins/compile-details.tsx +++ b/apps/remix-ide/src/app/plugins/compile-details.tsx @@ -1,11 +1,10 @@ import React from 'react' import { ViewPlugin } from '@remixproject/engine-web' import { PluginViewWrapper } from '@remix-ui/helper' +import { trackMatomoEvent, PluginEvents } from '@remix-api' import { RemixAppManager } from '../../remixAppManager' import { RemixUiCompileDetails } from '@remix-ui/solidity-compile-details' -const _paq = (window._paq = window._paq || []) - const profile = { name: 'compilationDetails', displayName: 'Solidity Compile Details', @@ -35,7 +34,7 @@ export class CompilationDetailsPlugin extends ViewPlugin { } async onActivation() { - _paq.push(['trackEvent', 'plugin', 'activated', 'compilationDetails']) + trackMatomoEvent(this, PluginEvents.activated('compilationDetails')) } onDeactivation(): void { diff --git a/apps/remix-ide/src/app/plugins/contractFlattener.tsx b/apps/remix-ide/src/app/plugins/contractFlattener.tsx index 46176cc07c1..e1d819a3145 100644 --- a/apps/remix-ide/src/app/plugins/contractFlattener.tsx +++ b/apps/remix-ide/src/app/plugins/contractFlattener.tsx @@ -1,11 +1,12 @@ /* eslint-disable prefer-const */ import React from 'react' +import { ViewPlugin } from '@remixproject/engine-web' +import { PluginViewWrapper } from '@remix-ui/helper' +import { trackMatomoEvent, PluginEvents } from '@remix-api' +import type { CompilerInput, CompilationSource } from '@remix-project/remix-solidity' import { Plugin } from '@remixproject/engine' import { customAction } from '@remixproject/plugin-api' import { concatSourceFiles, getDependencyGraph, normalizeContractPath } from '@remix-ui/solidity-compiler' -import type { CompilerInput, CompilationSource } from '@remix-project/remix-solidity' - -const _paq = (window._paq = window._paq || []) const profile = { name: 'contractflattener', @@ -31,7 +32,7 @@ export class ContractFlattener extends Plugin { } } }) - _paq.push(['trackEvent', 'plugin', 'activated', 'contractFlattener']) + trackMatomoEvent(this, PluginEvents.activated('contractFlattener')) } onDeactivation(): void { @@ -68,7 +69,7 @@ export class ContractFlattener extends Plugin { console.warn(err) } await this.call('fileManager', 'writeFile', path, result) - _paq.push(['trackEvent', 'plugin', 'contractFlattener', 'flattenAContract']) + trackMatomoEvent(this, PluginEvents.contractFlattener('flattenAContract')) // clean up memory references & return result sorted = null sources = null diff --git a/apps/remix-ide/src/app/plugins/desktop-client.tsx b/apps/remix-ide/src/app/plugins/desktop-client.tsx index 93fb24eb4fc..3ec2296950c 100644 --- a/apps/remix-ide/src/app/plugins/desktop-client.tsx +++ b/apps/remix-ide/src/app/plugins/desktop-client.tsx @@ -1,6 +1,6 @@ /* eslint-disable prefer-const */ import React from 'react' -import { desktopConnection, desktopConnectionType } from '@remix-api' +import { desktopConnection, desktopConnectionType, trackMatomoEvent, PluginEvents } from '@remix-api' import { Blockchain } from '../../blockchain/blockchain' import { AppAction, AppModal, ModalTypes } from '@remix-ui/app' import { ViewPlugin } from '@remixproject/engine-web' @@ -12,8 +12,6 @@ import DesktopClientUI from '../components/DesktopClientUI' // Import the UI com import JSONbig from 'json-bigint' import { Provider } from '@remix-ui/environment-explorer' -const _paq = (window._paq = window._paq || []) - const profile = { name: 'desktopClient', displayName: 'desktopClient', @@ -56,7 +54,7 @@ export class DesktopClient extends ViewPlugin { onActivation() { console.log('DesktopClient activated') - _paq.push(['trackEvent', 'plugin', 'activated', 'DesktopClient']) + trackMatomoEvent(this, PluginEvents.activated('DesktopClient')) this.connectToWebSocket() diff --git a/apps/remix-ide/src/app/plugins/electron/desktopHostPlugin.ts b/apps/remix-ide/src/app/plugins/electron/desktopHostPlugin.ts index 1f9c6938013..77399994936 100644 --- a/apps/remix-ide/src/app/plugins/electron/desktopHostPlugin.ts +++ b/apps/remix-ide/src/app/plugins/electron/desktopHostPlugin.ts @@ -2,8 +2,7 @@ import React from 'react' import { Plugin } from '@remixproject/engine' import { ElectronPlugin } from '@remixproject/engine-electron' - -const _paq = (window._paq = window._paq || []) +import { trackMatomoEvent, PluginEvents } from '@remix-api' const profile = { name: 'desktopHost', @@ -23,7 +22,7 @@ export class DesktopHost extends ElectronPlugin { onActivation() { console.log('DesktopHost activated') - _paq.push(['trackEvent', 'plugin', 'activated', 'DesktopHost']) + trackMatomoEvent(this, PluginEvents.activated('DesktopHost')) } } \ No newline at end of file diff --git a/apps/remix-ide/src/app/plugins/matomo.ts b/apps/remix-ide/src/app/plugins/matomo.ts index 40c61e718e7..903b5f4d1be 100644 --- a/apps/remix-ide/src/app/plugins/matomo.ts +++ b/apps/remix-ide/src/app/plugins/matomo.ts @@ -1,26 +1,234 @@ 'use strict' import { Plugin } from '@remixproject/engine' - -const _paq = window._paq = window._paq || [] +import { MatomoEvent } from '@remix-api' +import MatomoManager, { IMatomoManager, InitializationOptions, InitializationPattern, MatomoCommand, MatomoConfig, MatomoDiagnostics, MatomoState, MatomoStatus, ModeSwitchOptions, TrackingMode } from '../matomo/MatomoManager' const profile = { name: 'matomo', description: 'send analytics to Matomo', - methods: ['track'], - events: [''], + methods: [ + 'track', 'getManager', 'initialize', 'switchMode', 'giveConsent', 'revokeConsent', + 'trackEvent', 'trackPageView', 'setCustomDimension', 'getState', 'getStatus', + 'isMatomoLoaded', 'getMatomoCookies', 'deleteMatomoCookies', 'loadScript', + 'waitForLoad', 'getPreInitQueue', 'getQueueStatus', 'processPreInitQueue', + 'clearPreInitQueue', 'testConsentBehavior', 'getDiagnostics', 'inspectPaqArray', + 'batch', 'reset', 'addMatomoListener', 'removeMatomoListener', 'getMatomoManager', + 'shouldShowConsentDialog', 'getBotDetectionResult', 'isBot', 'getBotType', 'getBotConfidence' + ], + events: ['matomo-initialized', 'matomo-consent-changed', 'matomo-mode-switched'], version: '1.0.0' } -const allowedPlugins = ['LearnEth', 'etherscan', 'vyper', 'circuit-compiler', 'doc-gen', 'doc-viewer', 'solhint', 'walletconnect', 'scriptRunner', 'scriptRunnerBridge', 'dgit', 'contract-verification', 'noir-compiler'] - +const matomoManager = window._matomoManagerInstance export class Matomo extends Plugin { constructor() { super(profile) } - async track(data: string[]) { - if (!allowedPlugins.includes(this.currentRequest.from)) return - _paq.push(data) + /** + * Get the full IMatomoManager interface + * Use this to access all MatomoManager functionality including event listeners + * Example: this.call('matomo', 'getManager').trackEvent('category', 'action') + */ + getManager(): IMatomoManager { + return matomoManager + } + + // ================== INITIALIZATION METHODS ================== + + async initialize(pattern?: InitializationPattern, options?: InitializationOptions): Promise { + return matomoManager.initialize(pattern, options) + } + + async loadScript(): Promise { + return matomoManager.loadScript() + } + + async waitForLoad(timeout?: number): Promise { + return matomoManager.waitForLoad(timeout) + } + + // ================== MODE SWITCHING & CONSENT ================== + + async switchMode(mode: TrackingMode, options?: ModeSwitchOptions & { processQueue?: boolean }): Promise { + return matomoManager.switchMode(mode, options) + } + + async giveConsent(options?: { processQueue?: boolean }): Promise { + return matomoManager.giveConsent(options) + } + + async revokeConsent(): Promise { + return matomoManager.revokeConsent() + } + + // ================== TRACKING METHODS ================== + + // Support both type-safe MatomoEvent objects and legacy string signatures + trackEvent(event: MatomoEvent): number; + trackEvent(category: string, action: string, name?: string, value?: string | number): number; + trackEvent(eventObjOrCategory: MatomoEvent | string, action?: string, name?: string, value?: string | number): number { + if (typeof eventObjOrCategory === 'string') { + // Legacy string-based approach - convert to type-safe call + return matomoManager.trackEvent(eventObjOrCategory, action!, name, value) + } else { + // Type-safe MatomoEvent object + return matomoManager.trackEvent(eventObjOrCategory) + } + } + + trackPageView(title?: string): void { + return matomoManager.trackPageView(title) + } + + setCustomDimension(id: number, value: string): void { + return matomoManager.setCustomDimension(id, value) + } + + // ================== STATE & STATUS ================== + + getState(): MatomoState & MatomoStatus { + return matomoManager.getState() + } + + getStatus(): MatomoStatus { + return matomoManager.getStatus() + } + + isMatomoLoaded(): boolean { + return matomoManager.isMatomoLoaded() + } + + getMatomoCookies(): string[] { + return matomoManager.getMatomoCookies() + } + + async deleteMatomoCookies(): Promise { + return matomoManager.deleteMatomoCookies() + } + + // ================== QUEUE MANAGEMENT ================== + + getPreInitQueue(): MatomoCommand[] { + return matomoManager.getPreInitQueue() + } + + getQueueStatus(): { queueLength: number; initialized: boolean; commands: MatomoCommand[] } { + return matomoManager.getQueueStatus() + } + + async processPreInitQueue(): Promise { + return matomoManager.processPreInitQueue() + } + + clearPreInitQueue(): number { + return matomoManager.clearPreInitQueue() + } + + // ================== UTILITY & DIAGNOSTICS ================== + + async testConsentBehavior(): Promise { + return matomoManager.testConsentBehavior() + } + + getDiagnostics(): MatomoDiagnostics { + return matomoManager.getDiagnostics() + } + + inspectPaqArray(): { length: number; contents: any[]; trackingCommands: any[] } { + return matomoManager.inspectPaqArray() + } + + batch(commands: MatomoCommand[]): void { + return matomoManager.batch(commands) + } + + async reset(): Promise { + return matomoManager.reset() + } + + // ================== EVENT SYSTEM ================== + + /** + * Add event listener to MatomoManager events + * Note: Renamed to avoid conflict with Plugin base class + */ + addMatomoListener(event: string, callback: (data: T) => void): void { + return matomoManager.on(event, callback) + } + + /** + * Remove event listener from MatomoManager events + * Note: Renamed to avoid conflict with Plugin base class + */ + removeMatomoListener(event: string, callback: (data: T) => void): void { + return matomoManager.off(event, callback) + } + + // ================== PLUGIN-SPECIFIC METHODS ================== + + /** + * Get direct access to the underlying MatomoManager instance + * Use this if you need access to methods not exposed by the interface + */ + getMatomoManager(): MatomoManager { + return matomoManager + } + + /** + * Check whether the Matomo consent dialog should be shown + */ + shouldShowConsentDialog(configApi?: any): boolean { + return matomoManager.shouldShowConsentDialog(configApi) + } + + // ================== BOT DETECTION METHODS ================== + + /** + * Get full bot detection result with details + */ + getBotDetectionResult() { + return matomoManager.getBotDetectionResult() + } + + /** + * Check if current visitor is detected as a bot + */ + isBot(): boolean { + return matomoManager.isBot() + } + + /** + * Get the type of bot detected (or 'human' if not a bot) + */ + getBotType(): string { + return matomoManager.getBotType() + } + + /** + * Get confidence level of bot detection + */ + getBotConfidence(): 'high' | 'medium' | 'low' | null { + return matomoManager.getBotConfidence() + } + + /** + * Track events using type-safe MatomoEvent objects or legacy string parameters + * @param eventObjOrCategory Type-safe MatomoEvent object or category string + * @param action Action string (if using legacy approach) + * @param name Optional name parameter + * @param value Optional value parameter (string or number) + */ + async track(event: MatomoEvent): Promise; + async track(category: string, action: string, name?: string, value?: string | number): Promise; + async track(eventObjOrCategory: MatomoEvent | string, action?: string, name?: string, value?: string | number): Promise { + if (typeof eventObjOrCategory === 'string') { + // Legacy string-based approach + await matomoManager.trackEvent(eventObjOrCategory, action!, name, value); + } else { + // Type-safe MatomoEvent object + await matomoManager.trackEvent(eventObjOrCategory); + } } } \ No newline at end of file diff --git a/apps/remix-ide/src/app/plugins/remix-ai-assistant.tsx b/apps/remix-ide/src/app/plugins/remix-ai-assistant.tsx index c2deb753180..151df2141e9 100644 --- a/apps/remix-ide/src/app/plugins/remix-ai-assistant.tsx +++ b/apps/remix-ide/src/app/plugins/remix-ai-assistant.tsx @@ -4,6 +4,7 @@ import * as packageJson from '../../../../../package.json' import { PluginViewWrapper } from '@remix-ui/helper' import { ChatMessage, RemixUiRemixAiAssistant, RemixUiRemixAiAssistantHandle } from '@remix-ui/remix-ai-assistant' import { EventEmitter } from 'events' +import { trackMatomoEvent, AIEvents, RemixAIEvents } from '@remix-api' const profile = { name: 'remixaiassistant', @@ -19,7 +20,7 @@ const profile = { events: [], methods: ['chatPipe'] } -const _paq = (window._paq = window._paq || []) + export class RemixAIAssistant extends ViewPlugin { element: HTMLDivElement dispatch: React.Dispatch = () => { } @@ -101,7 +102,8 @@ export class RemixAIAssistant extends ViewPlugin { } async handleActivity(type: string, payload: any) { - (window as any)._paq?.push(['trackEvent', 'remixai-assistant', `${type}-${payload}`]) + // Use the proper type-safe tracking helper with RemixAI events + trackMatomoEvent(this, AIEvents.remixAI(`${type}-${payload}`)) } updateComponent(state: { diff --git a/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx b/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx index 48f4433cc0b..a10121d910d 100644 --- a/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx +++ b/apps/remix-ide/src/app/plugins/remixAIPlugin.tsx @@ -1,10 +1,10 @@ import * as packageJson from '../../../../../package.json' import { Plugin } from '@remixproject/engine'; +import { trackMatomoEvent, AIEvents } from '@remix-api' import { IModel, RemoteInferencer, IRemoteModel, IParams, GenerationParams, AssistantParams, CodeExplainAgent, SecurityAgent, CompletionParams, OllamaInferencer, isOllamaAvailable, getBestAvailableModel } from '@remix/remix-ai-core'; import { CodeCompletionAgent, ContractAgent, workspaceAgent, IContextType } from '@remix/remix-ai-core'; import axios from 'axios'; import { endpointUrls } from "@remix-endpoints-helper" -const _paq = (window._paq = window._paq || []) type chatRequestBufferT = { [key in keyof T]: T[key] @@ -195,7 +195,7 @@ export class RemixAIPlugin extends Plugin { params.threadId = newThreadID params.provider = 'anthropic' // enforce all generation to be only on anthropic useRag = false - _paq.push(['trackEvent', 'ai', 'remixAI', 'GenerateNewAIWorkspace']) + trackMatomoEvent(this, AIEvents.remixAI('GenerateNewAIWorkspace')) let userPrompt = '' if (useRag) { @@ -239,7 +239,7 @@ export class RemixAIPlugin extends Plugin { params.threadId = newThreadID params.provider = this.assistantProvider useRag = false - _paq.push(['trackEvent', 'ai', 'remixAI', 'WorkspaceAgentEdit']) + trackMatomoEvent(this, AIEvents.remixAI('WorkspaceAgentEdit')) await statusCallback?.('Performing workspace request...') if (useRag) { @@ -310,7 +310,7 @@ export class RemixAIPlugin extends Plugin { else { console.log("chatRequestBuffer is not empty. First process the last request.", this.chatRequestBuffer) } - _paq.push(['trackEvent', 'ai', 'remixAI', 'remixAI_chat']) + trackMatomoEvent(this, AIEvents.remixAI('remixAI_chat')) } async ProcessChatRequestBuffer(params:IParams=GenerationParams){ diff --git a/apps/remix-ide/src/app/plugins/remixGuide.tsx b/apps/remix-ide/src/app/plugins/remixGuide.tsx index 39ae0cb59a8..86b4ccd904a 100644 --- a/apps/remix-ide/src/app/plugins/remixGuide.tsx +++ b/apps/remix-ide/src/app/plugins/remixGuide.tsx @@ -2,14 +2,13 @@ import React, {useState} from 'react' // eslint-disable-line import { ViewPlugin } from '@remixproject/engine-web' import { PluginViewWrapper } from '@remix-ui/helper' +import { trackMatomoEvent, PluginEvents, RemixGuideEvents } from '@remix-api' import { RemixAppManager } from '../../remixAppManager' import { RemixUIGridView } from '@remix-ui/remix-ui-grid-view' import { RemixUIGridSection } from '@remix-ui/remix-ui-grid-section' import { RemixUIGridCell } from '@remix-ui/remix-ui-grid-cell' import * as Data from './remixGuideData.json' import './remixGuide.css' -//@ts-ignore -const _paq = (window._paq = window._paq || []) const profile = { name: 'remixGuide', @@ -47,7 +46,7 @@ export class RemixGuidePlugin extends ViewPlugin { this.handleThemeChange() await this.call('tabs', 'focus', 'remixGuide') this.renderComponent() - _paq.push(['trackEvent', 'plugin', 'activated', 'remixGuide']) + trackMatomoEvent(this, PluginEvents.activated('remixGuide')) // Read the data this.payload.data = Data this.handleKeyDown = (event) => { @@ -135,7 +134,7 @@ export class RemixGuidePlugin extends ViewPlugin { this.showVideo = true this.videoID = cell.expandViewElement.videoID this.renderComponent() - _paq.push(['trackEvent', 'remixGuide', 'playGuide', cell.title]) + trackMatomoEvent(this, RemixGuideEvents.playGuide(cell.title)) }} > ) - _paq.push(['trackEvent', 'udapp', 'hardhat', 'console.log']) + trackMatomoEvent(this, UdappEvents.hardhat('console.log')) this.call('terminal', 'logHtml', finalLogs) } } diff --git a/apps/remix-ide/src/app/plugins/solidity-umlgen.tsx b/apps/remix-ide/src/app/plugins/solidity-umlgen.tsx index 9ae582f29f9..55892337947 100644 --- a/apps/remix-ide/src/app/plugins/solidity-umlgen.tsx +++ b/apps/remix-ide/src/app/plugins/solidity-umlgen.tsx @@ -1,6 +1,7 @@ /* eslint-disable @nrwl/nx/enforce-module-boundaries */ import { ViewPlugin } from '@remixproject/engine-web' import React from 'react' +import { trackMatomoEvent, SolidityUMLGenEvents } from '@remix-api' // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries import { RemixUiSolidityUmlGen } from '@remix-ui/solidity-uml-gen' import { ISolidityUmlGen, ThemeQualityType, ThemeSummary } from 'libs/remix-ui/solidity-uml-gen/src/types' @@ -14,8 +15,6 @@ import { ClassOptions } from 'sol2uml/lib/converterClass2Dot' import type { CompilerInput } from '@remix-project/remix-solidity' const parser = (window as any).SolidityParser -const _paq = (window._paq = window._paq || []) - const profile = { name: 'solidityumlgen', displayName: 'Solidity UML Generator', @@ -89,7 +88,7 @@ export class SolidityUmlGen extends ViewPlugin implements ISolidityUmlGen { }) const payload = vizRenderStringSync(umlDot) this.updatedSvg = payload - _paq.push(['trackEvent', 'solidityumlgen', 'umlgenerated']) + trackMatomoEvent(this, SolidityUMLGenEvents.umlgenerated()) this.renderComponent() await this.call('tabs', 'focus', 'solidityumlgen') } catch (error) { @@ -126,7 +125,7 @@ export class SolidityUmlGen extends ViewPlugin implements ISolidityUmlGen { generateCustomAction = async (action: customAction) => { this.triggerGenerateUml = true this.updatedSvg = this.updatedSvg.startsWith(' { this.opts = {} - _paq.push(['trackEvent', 'template-selection', 'addToCurrentWorkspace', item.value]) + trackMatomoEvent(this, TemplateSelectionEvents.addToCurrentWorkspace(item.value)) if (templateGroup.hasOptions) { const modal: AppModal = { id: 'TemplatesSelection', diff --git a/apps/remix-ide/src/app/plugins/vyper-compilation-details.tsx b/apps/remix-ide/src/app/plugins/vyper-compilation-details.tsx index faac09ff9c5..80328a50bab 100644 --- a/apps/remix-ide/src/app/plugins/vyper-compilation-details.tsx +++ b/apps/remix-ide/src/app/plugins/vyper-compilation-details.tsx @@ -1,11 +1,12 @@ import React from 'react' import { ViewPlugin } from '@remixproject/engine-web' import { PluginViewWrapper } from '@remix-ui/helper' +import { trackMatomoEvent, PluginEvents } from '@remix-api' import { RemixAppManager } from '../../remixAppManager' import { RemixUiVyperCompileDetails } from '@remix-ui/vyper-compile-details' import { ThemeKeys, ThemeObject } from '@microlink/react-json-view' //@ts-ignore -const _paq = (window._paq = window._paq || []) +import * as packageJson from '../../../../../package.json' const profile = { name: 'vyperCompilationDetails', @@ -41,7 +42,7 @@ export class VyperCompilationDetailsPlugin extends ViewPlugin { this.handleThemeChange() await this.call('tabs', 'focus', 'vyperCompilationDetails') this.renderComponent() - _paq.push(['trackEvent', 'plugin', 'activated', 'vyperCompilationDetails']) + trackMatomoEvent(this, PluginEvents.activated('vyperCompilationDetails')) } onDeactivation(): void { diff --git a/apps/remix-ide/src/app/providers/environment-explorer.tsx b/apps/remix-ide/src/app/providers/environment-explorer.tsx index d84357df1fd..8173186e699 100644 --- a/apps/remix-ide/src/app/providers/environment-explorer.tsx +++ b/apps/remix-ide/src/app/providers/environment-explorer.tsx @@ -6,8 +6,6 @@ import { EnvironmentExplorerUI, Provider } from '@remix-ui/environment-explorer' import * as packageJson from '../../../../../package.json' -const _paq = (window._paq = window._paq || []) - const profile = { name: 'environmentExplorer', displayName: 'Environment Explorer', diff --git a/apps/remix-ide/src/app/tabs/compile-and-run.ts b/apps/remix-ide/src/app/tabs/compile-and-run.ts index 9643cd78e97..13600b54ecf 100644 --- a/apps/remix-ide/src/app/tabs/compile-and-run.ts +++ b/apps/remix-ide/src/app/tabs/compile-and-run.ts @@ -1,11 +1,6 @@ import { Plugin } from '@remixproject/engine' import * as packageJson from '../../../../../package.json' -declare global { - interface Window { - _paq: any - } -} -const _paq = window._paq = window._paq || [] +import { trackMatomoEvent, ScriptExecutorEvents } from '@remix-api' export const profile = { name: 'compileAndRun', @@ -34,11 +29,11 @@ export class CompileAndRun extends Plugin { e.preventDefault() this.targetFileName = file await this.call('solidity', 'compile', file) - _paq.push(['trackEvent', 'ScriptExecutor', 'CompileAndRun', 'compile_solidity']) + trackMatomoEvent(this, ScriptExecutorEvents.compileAndRun('compile_solidity')) } else if (file.endsWith('.js') || file.endsWith('.ts')) { e.preventDefault() this.runScript(file, false) - _paq.push(['trackEvent', 'ScriptExecutor', 'CompileAndRun', 'run_script']) + trackMatomoEvent(this, ScriptExecutorEvents.compileAndRun('run_script')) } } } @@ -47,7 +42,7 @@ export class CompileAndRun extends Plugin { runScriptAfterCompilation (fileName: string) { this.targetFileName = fileName - _paq.push(['trackEvent', 'ScriptExecutor', 'CompileAndRun', 'request_run_script']) + trackMatomoEvent(this, ScriptExecutorEvents.compileAndRun('request_run_script')) } async runScript (fileName, clearAllInstances) { @@ -78,7 +73,7 @@ export class CompileAndRun extends Plugin { const file = contract.object.devdoc['custom:dev-run-script'] if (file) { this.runScript(file, true) - _paq.push(['trackEvent', 'ScriptExecutor', 'CompileAndRun', 'run_script_after_compile']) + trackMatomoEvent(this, ScriptExecutorEvents.compileAndRun('run_script_after_compile')) } else { this.call('notification', 'toast', 'You have not set a script to run. Set it with @custom:dev-run-script NatSpec tag.') } diff --git a/apps/remix-ide/src/app/tabs/locale-module.js b/apps/remix-ide/src/app/tabs/locale-module.ts similarity index 81% rename from apps/remix-ide/src/app/tabs/locale-module.js rename to apps/remix-ide/src/app/tabs/locale-module.ts index 5ce5048ab14..c65e41230cb 100644 --- a/apps/remix-ide/src/app/tabs/locale-module.js +++ b/apps/remix-ide/src/app/tabs/locale-module.ts @@ -2,7 +2,15 @@ import { Plugin } from '@remixproject/engine' import { EventEmitter } from 'events' import { QueryParams } from '@remix-project/remix-lib' import * as packageJson from '../../../../../package.json' -import {Registry} from '@remix-project/remix-lib' +import { trackMatomoEvent, LocaleModuleEvents } from '@remix-api' +import { Registry } from '@remix-project/remix-lib' + +interface Locale { + code: string; + name: string; + localeName: string; + messages: any; +} import enJson from './locales/en' import zhJson from './locales/zh' import esJson from './locales/es' @@ -10,7 +18,6 @@ import frJson from './locales/fr' import itJson from './locales/it' import koJson from './locales/ko' import ruJson from './locales/ru' -const _paq = window._paq = window._paq || [] const locales = [ { code: 'zh', name: 'Chinese Simplified', localeName: 'įŽ€äŊ“中文', messages: zhJson }, @@ -31,6 +38,14 @@ const profile = { } export class LocaleModule extends Plugin { + events: EventEmitter; + _deps: { config?: any }; + locales: { [key: string]: Locale }; + queryParams: QueryParams; + currentLocaleState: { queryLocale: string | null; currentLocale: string | null }; + active: string; + forced: boolean; + constructor () { super(profile) this.events = new EventEmitter() @@ -41,9 +56,9 @@ export class LocaleModule extends Plugin { locales.forEach((locale) => { this.locales[locale.code.toLocaleLowerCase()] = locale }) - this._paq = _paq + // Tracking now handled via plugin API this.queryParams = new QueryParams() - let queryLocale = this.queryParams.get().lang + let queryLocale = this.queryParams.get()['lang'] as string queryLocale = queryLocale && queryLocale.toLocaleLowerCase() queryLocale = this.locales[queryLocale] ? queryLocale : null let currentLocale = (this._deps.config && this._deps.config.get('settings/locale')) || null @@ -56,12 +71,12 @@ export class LocaleModule extends Plugin { } /** Return the active locale */ - currentLocale () { + currentLocale (): Locale { return this.locales[this.active] } /** Returns all locales as an array */ - getLocales () { + getLocales (): Locale[] { return Object.keys(this.locales).map(key => this.locales[key]) } @@ -69,15 +84,15 @@ export class LocaleModule extends Plugin { * Change the current locale * @param {string} [localeCode] - The code of the locale */ - switchLocale (localeCode) { + switchLocale (localeCode?: string): void { localeCode = localeCode && localeCode.toLocaleLowerCase() if (localeCode && !Object.keys(this.locales).includes(localeCode)) { throw new Error(`Locale ${localeCode} doesn't exist`) } const next = localeCode || this.active // Name if (next === this.active) return // --> exit out of this method - _paq.push(['trackEvent', 'localeModule', 'switchTo', next]) - + trackMatomoEvent(this, LocaleModuleEvents.switchTo(next)) + const nextLocale = this.locales[next] // Locale if (!this.forced) this._deps.config.set('settings/locale', next) diff --git a/apps/remix-ide/src/app/tabs/runTab/model/recorder.js b/apps/remix-ide/src/app/tabs/runTab/model/recorder.ts similarity index 79% rename from apps/remix-ide/src/app/tabs/runTab/model/recorder.js rename to apps/remix-ide/src/app/tabs/runTab/model/recorder.ts index 85e36aaf802..5b07dfd6a4f 100644 --- a/apps/remix-ide/src/app/tabs/runTab/model/recorder.js +++ b/apps/remix-ide/src/app/tabs/runTab/model/recorder.ts @@ -1,28 +1,62 @@ -var async = require('async') -var remixLib = require('@remix-project/remix-lib') +import * as async from 'async' +import * as remixLib from '@remix-project/remix-lib' import { bytesToHex } from '@ethereumjs/util' import { hash } from '@remix-project/remix-lib' import { Plugin } from '@remixproject/engine' import * as packageJson from '../../../../.././../../package.json' -var EventManager = remixLib.EventManager -var format = remixLib.execution.txFormat -var txHelper = remixLib.execution.txHelper +import { trackMatomoEvent, RunEvents } from '@remix-api' +const EventManager = remixLib.EventManager +const format = remixLib.execution.txFormat +const txHelper = remixLib.execution.txHelper import { addressToString } from '@remix-ui/helper' -const _paq = window._paq = window._paq || [] //eslint-disable-line +interface RecorderData { + _listen: boolean; + _replay: boolean; + journal: any[]; + _createdContracts: { [key: string]: any }; + _createdContractsReverse: { [key: string]: any }; + _usedAccounts: { [key: string]: any }; + _abis: { [key: string]: any }; + _contractABIReferences: { [key: string]: any }; + _linkReferences: { [key: string]: any }; +} + +interface RecorderRecord { + value: any; + inputs: any; + parameters: any; + name: any; + type: any; + abi?: any; + contractName?: any; + bytecode?: any; + linkReferences?: any; + to?: any; + from?: any; +} + +interface JournalEntry { + timestamp: string | number; + record: RecorderRecord; +} const profile = { name: 'recorder', displayName: 'Recorder', description: 'Records transactions to save and run', version: packageJson.version, - methods: [ ] + methods: [] } /** * Record transaction as long as the user create them. */ export class Recorder extends Plugin { - constructor (blockchain) { + event: any; + blockchain: any; + data: RecorderData; + + constructor (blockchain: any) { super(profile) this.event = new EventManager() this.blockchain = blockchain @@ -30,27 +64,27 @@ export class Recorder extends Plugin { this.blockchain.event.register('initiatingTransaction', (timestamp, tx, payLoad) => { if (tx.useCall) return - var { from, to, value } = tx + const { from, to, value } = tx // convert to and from to tokens if (this.data._listen) { - var record = { + const record: RecorderRecord = { value, inputs: txHelper.serializeInputs(payLoad.funAbi), parameters: payLoad.funArgs, - name: payLoad.funAbi.name, + name: payLoad.funAbi.name, type: payLoad.funAbi.type } if (!to) { - var abi = payLoad.contractABI - var keccak = bytesToHex(hash.keccakFromString(JSON.stringify(abi))) + const abi = payLoad.contractABI + const keccak = bytesToHex(hash.keccakFromString(JSON.stringify(abi))) record.abi = keccak record.contractName = payLoad.contractName record.bytecode = payLoad.contractBytecode record.linkReferences = payLoad.linkReferences if (record.linkReferences && Object.keys(record.linkReferences).length) { - for (var file in record.linkReferences) { - for (var lib in record.linkReferences[file]) { + for (const file in record.linkReferences) { + for (const lib in record.linkReferences[file]) { this.data._linkReferences[lib] = '
' } } @@ -59,13 +93,13 @@ export class Recorder extends Plugin { this.data._contractABIReferences[timestamp] = keccak } else { - var creationTimestamp = this.data._createdContracts[to] + const creationTimestamp = this.data._createdContracts[to] record.to = `created{${creationTimestamp}}` record.abi = this.data._contractABIReferences[creationTimestamp] - } - for (var p in record.parameters) { - var thisarg = record.parameters[p] - var thistimestamp = this.data._createdContracts[thisarg] + } + for (const p in record.parameters) { + const thisarg = record.parameters[p] + const thistimestamp = this.data._createdContracts[thisarg] if (thistimestamp) record.parameters[p] = `created{${thistimestamp}}` } @@ -108,7 +142,7 @@ export class Recorder extends Plugin { } extractTimestamp (value) { - var stamp = /created{(.*)}/g.exec(value) + const stamp = /created{(.*)}/g.exec(value) if (stamp) { return stamp[1] } @@ -125,7 +159,7 @@ export class Recorder extends Plugin { */ resolveAddress (record, accounts, options) { if (record.to) { - var stamp = this.extractTimestamp(record.to) + const stamp = this.extractTimestamp(record.to) if (stamp) { record.to = this.data._createdContractsReverse[stamp] } @@ -152,13 +186,13 @@ export class Recorder extends Plugin { * */ getAll () { - var records = [].concat(this.data.journal) + const records = [].concat(this.data.journal) return { accounts: this.data._usedAccounts, linkReferences: this.data._linkReferences, transactions: records.sort((A, B) => { - var stampA = A.timestamp - var stampB = B.timestamp + const stampA = A.timestamp + const stampB = B.timestamp return stampA - stampB }), abis: this.data._abis @@ -203,7 +237,7 @@ export class Recorder extends Plugin { this.setListen(false) const liveMsg = liveMode ? ' with updated contracts' : '' logCallBack(`Running ${records.length} transaction(s)${liveMsg} ...`) - async.eachOfSeries(records, async (tx, index, cb) => { + async.eachOfSeries(records, async (tx: JournalEntry, index, cb) => { if (liveMode && tx.record.type === 'constructor') { // resolve the bytecode and ABI using the contract name, this ensure getting the last compiled one. const data = await this.call('compilerArtefacts', 'getArtefactsByContractName', tx.record.contractName) @@ -212,16 +246,16 @@ export class Recorder extends Plugin { abis[updatedABIKeccak] = data.artefact.abi tx.record.abi = updatedABIKeccak } - var record = this.resolveAddress(tx.record, accounts, options) - var abi = abis[tx.record.abi] + const record = this.resolveAddress(tx.record, accounts, options) + const abi = abis[tx.record.abi] if (!abi) { return alertCb('cannot find ABI for ' + tx.record.abi + '. Execution stopped at ' + index) } /* Resolve Library */ if (record.linkReferences && Object.keys(record.linkReferences).length) { - for (var k in linkReferences) { - var link = linkReferences[k] - var timestamp = this.extractTimestamp(link) + for (const k in linkReferences) { + let link = linkReferences[k] + const timestamp = this.extractTimestamp(link) if (timestamp && this.data._createdContractsReverse[timestamp]) { link = this.data._createdContractsReverse[timestamp] } @@ -229,7 +263,7 @@ export class Recorder extends Plugin { } } /* Encode params */ - var fnABI + let fnABI if (tx.record.type === 'constructor') { fnABI = txHelper.getConstructorInterface(abi) } else if (tx.record.type === 'fallback') { @@ -241,18 +275,18 @@ export class Recorder extends Plugin { } if (!fnABI) { alertCb('cannot resolve abi of ' + JSON.stringify(record, null, '\t') + '. Execution stopped at ' + index) - return cb('cannot resolve abi') + return cb(new Error('cannot resolve abi')) } if (tx.record.parameters) { /* check if we have some params to resolve */ try { tx.record.parameters.forEach((value, index) => { - var isString = true + let isString = true if (typeof value !== 'string') { isString = false value = JSON.stringify(value) } - for (var timestamp in this.data._createdContractsReverse) { + for (const timestamp in this.data._createdContractsReverse) { value = value.replace(new RegExp('created\\{' + timestamp + '\\}', 'g'), this.data._createdContractsReverse[timestamp]) } if (!isString) value = JSON.parse(value) @@ -262,10 +296,10 @@ export class Recorder extends Plugin { return alertCb('cannot resolve input parameters ' + JSON.stringify(tx.record.parameters) + '. Execution stopped at ' + index) } } - var data = format.encodeData(fnABI, tx.record.parameters, tx.record.bytecode) + const data = format.encodeData(fnABI, tx.record.parameters, tx.record.bytecode) if (data.error) { alertCb(data.error + '. Record:' + JSON.stringify(record, null, '\t') + '. Execution stopped at ' + index) - return cb(data.error) + return cb(new Error(data.error)) } logCallBack(`(${index}) ${JSON.stringify(record, null, '\t')}`) logCallBack(`(${index}) data: ${data.data}`) @@ -291,9 +325,9 @@ export class Recorder extends Plugin { } runScenario (liveMode, json, continueCb, promptCb, alertCb, confirmationCb, logCallBack, cb) { - _paq.push(['trackEvent', 'run', 'recorder', 'start']) + trackMatomoEvent(this, RunEvents.recorder('start')) if (!json) { - _paq.push(['trackEvent', 'run', 'recorder', 'wrong-json']) + trackMatomoEvent(this, RunEvents.recorder('wrong-json')) return cb('a json content must be provided') } if (typeof json === 'string') { diff --git a/apps/remix-ide/src/app/tabs/settings-tab.tsx b/apps/remix-ide/src/app/tabs/settings-tab.tsx index 69a653b65db..dec40a82785 100644 --- a/apps/remix-ide/src/app/tabs/settings-tab.tsx +++ b/apps/remix-ide/src/app/tabs/settings-tab.tsx @@ -2,15 +2,10 @@ import React from 'react' // eslint-disable-line import { ViewPlugin } from '@remixproject/engine-web' import * as packageJson from '../../../../../package.json' -import {RemixUiSettings} from '@remix-ui/settings' //eslint-disable-line +import { RemixUiSettings } from '@remix-ui/settings' //eslint-disable-line import { Registry } from '@remix-project/remix-lib' import { PluginViewWrapper } from '@remix-ui/helper' -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) +import { InitializationPattern, TrackingMode, MatomoState, CustomRemixApi } from '@remix-api' const profile = { name: 'settings', @@ -31,13 +26,21 @@ const profile = { export default class SettingsTab extends ViewPlugin { config: any = {} editor: any + + // Type-safe method for Matomo plugin calls + private async callMatomo( + method: K, + ...args: Parameters + ): Promise> { + return await this.call('matomo', method, ...args) + } private _deps: { themeModule: any } element: HTMLDivElement public useMatomoAnalytics: any public useMatomoPerfAnalytics: boolean - dispatch: React.Dispatch = () => {} + dispatch: React.Dispatch = () => { } constructor(config, editor) { super(profile) this.config = config @@ -100,42 +103,31 @@ export default class SettingsTab extends ViewPlugin { }) } - getCopilotSetting(){ + getCopilotSetting() { return this.get('settings/copilot/suggest/activate') } - updateMatomoAnalyticsChoice(isChecked) { - this.config.set('settings/matomo-analytics', isChecked) - // set timestamp to local storage to track when the user has given consent - localStorage.setItem('matomo-analytics-consent', Date.now().toString()) - this.useMatomoAnalytics = isChecked - if (!isChecked) { - // revoke tracking consent - _paq.push(['forgetConsentGiven']); - } else { - // user has given consent to process their data - _paq.push(['setConsentGiven']); - } - this.dispatch({ - ...this - }) - } - - updateMatomoPerfAnalyticsChoice(isChecked) { + async updateMatomoPerfAnalyticsChoice(isChecked) { + console.log('[Matomo][settings] updateMatomoPerfAnalyticsChoice called with', isChecked) this.config.set('settings/matomo-perf-analytics', isChecked) - // set timestamp to local storage to track when the user has given consent + // Timestamp consent indicator (we treat enabling perf as granting cookie consent; disabling as revoking) localStorage.setItem('matomo-analytics-consent', Date.now().toString()) this.useMatomoPerfAnalytics = isChecked - this.emit('matomoPerfAnalyticsChoiceUpdated', isChecked) - if (!isChecked) { - // revoke tracking consent for performance data - _paq.push(['disableCookies']) + + const mode: TrackingMode = isChecked ? 'cookie' : 'anonymous' + const matomoState = await this.callMatomo('getState') + if (matomoState.initialized == false) { + const pattern: InitializationPattern = isChecked ? "immediate" : "anonymous" + await this.callMatomo('initialize', pattern) + console.log('[Matomo][settings] Matomo initialized with mode', pattern) + await this.callMatomo('processPreInitQueue') } else { - // user has given consent to process their performance data - _paq.push(['setCookieConsentGiven']) + await this.callMatomo('switchMode', mode) } - this.dispatch({ - ...this - }) + + this.useMatomoAnalytics = true + this.emit('matomoPerfAnalyticsChoiceUpdated', isChecked); + this.dispatch({ ...this }) } + } diff --git a/apps/remix-ide/src/app/tabs/theme-module.js b/apps/remix-ide/src/app/tabs/theme-module.ts similarity index 83% rename from apps/remix-ide/src/app/tabs/theme-module.js rename to apps/remix-ide/src/app/tabs/theme-module.ts index e43d24d4871..ded29924564 100644 --- a/apps/remix-ide/src/app/tabs/theme-module.js +++ b/apps/remix-ide/src/app/tabs/theme-module.ts @@ -2,9 +2,19 @@ import { Plugin } from '@remixproject/engine' import { EventEmitter } from 'events' import { QueryParams } from '@remix-project/remix-lib' import * as packageJson from '../../../../../package.json' -import {Registry} from '@remix-project/remix-lib' -const isElectron = require('is-electron') -const _paq = window._paq = window._paq || [] +import { Registry } from '@remix-project/remix-lib' +import { trackMatomoEvent, ThemeModuleEvents } from '@remix-api' +import isElectron from 'is-electron' + +interface Theme { + name: string; + quality: 'dark' | 'light'; + url: string; + backgroundColor: string; + textColor: string; + shapeColor: string; + fillColor: string; +} //sol2uml dot files cannot work with css variables so hex values for colors are used const themes = [ @@ -23,6 +33,14 @@ const profile = { } export class ThemeModule extends Plugin { + events: EventEmitter; + _deps: { config?: any }; + themes: { [key: string]: Theme }; + currentThemeState: { queryTheme: string | null; currentTheme: string | null }; + active: string; + forced: boolean; + initCallback?: () => void; + constructor() { super(profile) this.events = new EventEmitter() @@ -33,15 +51,16 @@ export class ThemeModule extends Plugin { themes.map((theme) => { this.themes[theme.name.toLocaleLowerCase()] = { ...theme, + quality: theme.quality as 'dark' | 'light', url: isElectron() ? theme.url : window.location.pathname.startsWith('/auth') ? window.location.origin + '/' + theme.url : window.location.origin + (window.location.pathname.startsWith('/address/') || window.location.pathname.endsWith('.sol') ? '/' : window.location.pathname) + theme.url - } + } as Theme }) - this._paq = _paq - let queryTheme = (new QueryParams()).get().theme + // Tracking now handled via plugin API + let queryTheme = (new QueryParams()).get()['theme'] as string queryTheme = queryTheme && queryTheme.toLocaleLowerCase() queryTheme = this.themes[queryTheme] ? queryTheme : null let currentTheme = (this._deps.config && this._deps.config.get('settings/theme')) || null @@ -55,7 +74,7 @@ export class ThemeModule extends Plugin { /** Return the active theme * @return {{ name: string, quality: string, url: string }} - The active theme */ - currentTheme() { + currentTheme(): Theme { if (isElectron()) { const theme = 'https://remix.ethereum.org/' + this.themes[this.active].url.replace(/\\/g, '/').replace(/\/\//g, '/').replace(/\/$/g, '') return { ...this.themes[this.active], url: theme } @@ -64,14 +83,14 @@ export class ThemeModule extends Plugin { } /** Returns all themes as an array */ - getThemes() { + getThemes(): Theme[] { return Object.keys(this.themes).map(key => this.themes[key]) } /** * Init the theme */ - initTheme(callback) { // callback is setTimeOut in app.js which is always passed + initTheme(callback?: () => void): void { // callback is setTimeOut in app.js which is always passed if (callback) this.initCallback = callback if (this.active) { document.getElementById('theme-link') ? document.getElementById('theme-link').remove() : null @@ -94,20 +113,18 @@ export class ThemeModule extends Plugin { * Change the current theme * @param {string} [themeName] - The name of the theme */ - switchTheme (themeName) { + switchTheme(themeName?: string): void { themeName = themeName && themeName.toLocaleLowerCase() if (themeName && !Object.keys(this.themes).includes(themeName)) { throw new Error(`Theme ${themeName} doesn't exist`) } const next = themeName || this.active // Name if (next === this.active) return // --> exit out of this method - _paq.push(['trackEvent', 'themeModule', 'switchThemeTo', next]) + trackMatomoEvent(this, ThemeModuleEvents.switchThemeTo(next)) const nextTheme = this.themes[next] // Theme if (!this.forced) this._deps.config.set('settings/theme', next) document.getElementById('theme-link') ? document.getElementById('theme-link').remove() : null - - const theme = document.createElement('link') theme.setAttribute('rel', 'stylesheet') theme.setAttribute('href', nextTheme.url) @@ -134,7 +151,7 @@ export class ThemeModule extends Plugin { * fixes the inversion for images since this should be adjusted when we switch between dark/light qualified themes * @param {element} [image] - the dom element which invert should be fixed to increase visibility */ - fixInvert(image) { + fixInvert(image?: HTMLElement): void { const invert = this.currentTheme().quality === 'dark' ? 1 : 0 if (image) { image.style.filter = `invert(${invert})` diff --git a/apps/remix-ide/src/app/udapp/run-tab.tsx b/apps/remix-ide/src/app/udapp/run-tab.tsx index 73fe9efaf0e..36bd4f6f842 100644 --- a/apps/remix-ide/src/app/udapp/run-tab.tsx +++ b/apps/remix-ide/src/app/udapp/run-tab.tsx @@ -1,6 +1,7 @@ /* eslint-disable @nrwl/nx/enforce-module-boundaries */ import React from 'react' // eslint-disable-line import { RunTabUI } from '@remix-ui/run-tab' +import { trackMatomoEvent, UdappEvents } from '@remix-api' import { ViewPlugin } from '@remixproject/engine-web' import isElectron from 'is-electron' import { addressToString } from '@remix-ui/helper' @@ -14,7 +15,6 @@ import type { CompilerArtefacts } from '@remix-project/core-plugin' import { ForkedVMStateProvider } from '../providers/vm-provider' import { Recorder } from '../tabs/runTab/model/recorder' import { EnvDropdownLabelStateType } from 'libs/remix-ui/run-tab/src/lib/types' -const _paq = (window._paq = window._paq || []) export const providerLogos = { 'injected-metamask-optimism': ['assets/img/optimism-ethereum-op-logo.png', 'assets/img/metamask.png'], @@ -131,7 +131,7 @@ export class RunTab extends ViewPlugin { } sendTransaction(tx) { - _paq.push(['trackEvent', 'udapp', 'sendTx', 'udappTransaction']) + trackMatomoEvent(this, UdappEvents.sendTx('udappTransaction')) return this.blockchain.sendTransaction(tx) } diff --git a/apps/remix-ide/src/app/utils/AppRenderer.tsx b/apps/remix-ide/src/app/utils/AppRenderer.tsx new file mode 100644 index 00000000000..004a9d61308 --- /dev/null +++ b/apps/remix-ide/src/app/utils/AppRenderer.tsx @@ -0,0 +1,47 @@ +/** + * App Renderer + * + * Handles rendering the appropriate React component tree based on routing + */ + +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { TrackingProvider } from '../contexts/TrackingContext'; +import { Preload } from '../components/preload'; +import { GitHubPopupCallback } from '../pages/GitHubPopupCallback'; +import { TrackingFunction } from './TrackingFunction'; + +export interface RenderAppOptions { + trackingFunction: TrackingFunction; +} + +/** + * Render the appropriate React app component based on current URL + */ +export function renderApp(options: RenderAppOptions): Root | null { + const { trackingFunction } = options; + + const container = document.getElementById('root'); + if (!container) { + console.error('Root container not found'); + return null; + } + + const root = createRoot(container); + + if (window.location.hash.includes('source=github')) { + root.render( + + + + ); + } else { + root.render( + + + + ); + } + + return root; +} \ No newline at end of file diff --git a/apps/remix-ide/src/app/utils/AppSetup.ts b/apps/remix-ide/src/app/utils/AppSetup.ts new file mode 100644 index 00000000000..c72509016cd --- /dev/null +++ b/apps/remix-ide/src/app/utils/AppSetup.ts @@ -0,0 +1,25 @@ +/** + * App Theme and Locale Setup + * + * Handles initialization of theme and locale modules and registry setup + */ + +import { ThemeModule } from '../tabs/theme-module'; +import { LocaleModule } from '../tabs/locale-module'; +import { Registry } from '@remix-project/remix-lib'; + +/** + * Initialize theme and locale modules and register settings config + */ +export function setupThemeAndLocale(): void { + const theme = new ThemeModule(); + theme.initTheme(); + + const locale = new LocaleModule(); + const settingsConfig = { + themes: theme.getThemes(), + locales: locale.getLocales() + }; + + Registry.getInstance().put({ api: settingsConfig, name: 'settingsConfig' }); +} \ No newline at end of file diff --git a/apps/remix-ide/src/app/utils/TrackingFunction.ts b/apps/remix-ide/src/app/utils/TrackingFunction.ts new file mode 100644 index 00000000000..b1d813ac504 --- /dev/null +++ b/apps/remix-ide/src/app/utils/TrackingFunction.ts @@ -0,0 +1,24 @@ +/** + * Tracking Function Factory + * + * Creates a standardized tracking function that works with MatomoManager + */ + +import { MatomoEvent, MatomoEventBase } from '@remix-api'; +import { MatomoManager } from '../matomo/MatomoManager'; + +export type TrackingFunction = ( + event: MatomoEvent +) => void; + +/** + * Create a tracking function that properly delegates to MatomoManager + * Value can be either string or number as per Matomo API specification + */ +export function createTrackingFunction(matomoManager: MatomoManager): TrackingFunction { + return (event: MatomoEvent) => { + // Pass the event directly to MatomoManager without converting value + // Matomo API accepts both string and number for the value parameter + matomoManager.trackEvent?.(event); + }; +} \ No newline at end of file diff --git a/apps/remix-ide/src/assets/js/loader.js b/apps/remix-ide/src/assets/js/loader.js deleted file mode 100644 index 3d591f4ad4a..00000000000 --- a/apps/remix-ide/src/assets/js/loader.js +++ /dev/null @@ -1,99 +0,0 @@ -const domains = { - 'alpha.remix.live': 27, - 'beta.remix.live': 25, - 'remix.ethereum.org': 23, - 'localhost': 35 // remix desktop -} -const domainsOnPrem = { - 'alpha.remix.live': 1, - 'beta.remix.live': 2, - 'remix.ethereum.org': 3, - 'localhost': 4 // remix desktop -} - -let cloudDomainToTrack = domains[window.location.hostname] -let domainOnPremToTrack = domainsOnPrem[window.location.hostname] - - -function trackDomain(domainToTrack, u, paqName) { - var _paq = window[paqName] = window[paqName] || [] - - /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ - _paq.push(["setExcludedQueryParams", ["code", "gist"]]); - _paq.push(["setExcludedReferrers", ["etherscan.io"]]); - _paq.push(['enableJSErrorTracking']); - _paq.push(['trackPageView']); - _paq.push(['enableLinkTracking']); - _paq.push(['enableHeartBeatTimer']); - _paq.push(['setConsentGiven']); - _paq.push(['requireCookieConsent']); - _paq.push(['trackEvent', 'loader', 'load']); - (function () { - _paq.push(['setTrackerUrl', u + 'matomo.php']); - _paq.push(['setSiteId', domainToTrack]); - - /* unplug from the EF matomo instance - if (cloudDomainToTrack) { - const secondaryTrackerUrl = 'https://ethereumfoundation.matomo.cloud/matomo.php' - const secondaryWebsiteId = cloudDomainToTrack - _paq.push(['addTracker', secondaryTrackerUrl, secondaryWebsiteId]) - } - */ - - var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0]; - g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s); - })(); -} - -if (window.electronAPI) { - // desktop - window.electronAPI.canTrackMatomo().then((canTrack) => { - if (!canTrack) { - console.log('Matomo tracking is disabled on Dev mode') - return - } - window._paq = { - push: function (...data) { - if (!window.localStorage.getItem('config-v0.8:.remix.config') || - (window.localStorage.getItem('config-v0.8:.remix.config') && !window.localStorage.getItem('config-v0.8:.remix.config').includes('settings/matomo-analytics'))) { - // require user tracking consent before processing data - } else { - if (JSON.parse(window.localStorage.getItem('config-v0.8:.remix.config'))['settings/matomo-analytics']) { - window.electronAPI.trackEvent(...data) - } - } - } - } - }) -} else { - // live site but we don't track localhost - if (domainOnPremToTrack && window.location.hostname !== 'localhost') { - trackDomain(domainOnPremToTrack, 'https://matomo.remix.live/matomo/', '_paq') - } -} -function isElectron() { - // Renderer process - if (typeof window !== 'undefined' && typeof window.process === 'object' && window.process.type === 'renderer') { - return true - } - - // Main process - if (typeof process !== 'undefined' && typeof process.versions === 'object' && !!process.versions.electron) { - return true - } - - // Detect the user agent when the `nodeIntegration` option is set to false - if (typeof navigator === 'object' && typeof navigator.userAgent === 'string' && navigator.userAgent.indexOf('Electron') >= 0) { - return true - } - - return false -} - -const versionUrl = 'assets/version.json' -fetch(versionUrl, { cache: "no-store" }).then(response => { - response.text().then(function (data) { - const version = JSON.parse(data); - console.log(`Loading Remix ${version.version}`); - }); -}); diff --git a/apps/remix-ide/src/assets/js/matomo-debug-plugin.js b/apps/remix-ide/src/assets/js/matomo-debug-plugin.js new file mode 100644 index 00000000000..d5893b3c7f3 --- /dev/null +++ b/apps/remix-ide/src/assets/js/matomo-debug-plugin.js @@ -0,0 +1,297 @@ +/** + * Matomo Debug Plugin + * + * Debugging plugin for Matomo tracking data capture and analysis. + */ + + +// Main plugin initialization function +function initMatomoDebugPlugin() { + console.log('[MatomoDebugPlugin] === INITIALIZATION STARTING ==='); + + + + // Initialize data storage + if (!window.__matomoDebugData) { + window.__matomoDebugData = { + requests: [], + events: [], + pageViews: [], + dimensions: {}, + visitorIds: [] + }; + } + + + + // Helper functions - always available globally + window.__getMatomoDebugData = function() { + return window.__matomoDebugData || { + requests: [], + events: [], + pageViews: [], + dimensions: {}, + visitorIds: [] + }; + }; + + window.__getLatestVisitorId = function() { + const data = window.__matomoDebugData; + if (!data || !data.visitorIds.length) return null; + + const latest = data.visitorIds[data.visitorIds.length - 1]; + return { + visitorId: latest.visitorId, + isNull: latest.isNull, + timestamp: latest.timestamp + }; + }; + + window.__getMatomoDimensions = function() { + const data = window.__matomoDebugData; + return data ? data.dimensions : {}; + }; + + window.__clearMatomoDebugData = function() { + window.__matomoDebugData = { + requests: [], + events: [], + pageViews: [], + dimensions: {}, + visitorIds: [] + }; + }; + + // Helper functions to get parsed data + window.__getMatomoEvents = function() { + const data = window.__matomoDebugData; + return data ? data.events : []; + }; + + window.__getMatomoPageViews = function() { + const data = window.__matomoDebugData; + return data ? data.pageViews : []; + }; + + window.__getLatestMatomoEvent = function() { + const events = window.__getMatomoEvents(); + return events.length > 0 ? events[events.length - 1] : null; + }; + + window.__getMatomoEventsByCategory = function(category) { + const events = window.__getMatomoEvents(); + return events.filter(event => event.category === category); + }; + + window.__getMatomoEventsByAction = function(action) { + const events = window.__getMatomoEvents(); + return events.filter(event => event.action === action); + }; + + // Helper function to parse visitor ID from request + function parseVisitorId(request) { + if (!request) return null; + + console.log('[DEBUG] parseVisitorId - Full request:', request); + + // Look for _id parameter - this IS the visitor ID (not a separate userId) + // In anonymous mode: _id= (empty) + // In cookie mode: _id=18d92915c3022ce2 (has value) + const match = request.match(/_id=([^&]*)/); + console.log('[DEBUG] parseVisitorId - _id match:', match); + + if (match) { + const visitorIdValue = match[1]; + console.log('[DEBUG] parseVisitorId - Raw _id value:', `"${visitorIdValue}"`); + + // If _id is empty or null, this is anonymous mode + if (!visitorIdValue || visitorIdValue === '' || visitorIdValue === 'null' || visitorIdValue === 'undefined') { + console.log('[DEBUG] parseVisitorId - Anonymous mode (_id is empty), returning null'); + return null; + } + + // If _id has a value, this is cookie mode + const visitorId = decodeURIComponent(visitorIdValue); + console.log('[DEBUG] parseVisitorId - Cookie mode (_id has value), returning:', visitorId); + return visitorId; + } + + // _id parameter not found at all + console.log('[DEBUG] parseVisitorId - No _id parameter found, returning null'); + return null; + } + + // Helper function to parse event data from request string + function parseEventData(request) { + if (!request) return null; + + try { + const params = new URLSearchParams(request); + + // Check if this is an event (has e_c parameter) + const eventCategory = params.get('e_c'); + if (!eventCategory) return null; + + const eventData = { + category: decodeURIComponent(eventCategory || ''), + action: decodeURIComponent(params.get('e_a') || ''), + name: decodeURIComponent(params.get('e_n') || ''), + value: params.get('e_v') ? parseFloat(params.get('e_v')) : null, + visitorId: parseVisitorId(request), // From _id parameter: has value in cookie mode, null in anonymous mode + dimension1: params.get('dimension1') ? decodeURIComponent(params.get('dimension1')) : null, // tracking mode + dimension2: params.get('dimension2') ? decodeURIComponent(params.get('dimension2')) : null, + dimension3: params.get('dimension3') ? decodeURIComponent(params.get('dimension3')) : null, + url: params.get('url') ? decodeURIComponent(params.get('url')) : null, + referrer: params.get('urlref') ? decodeURIComponent(params.get('urlref')) : null, + timestamp: Date.now() + }; + + return eventData; + + } catch (e) { + console.error('[MatomoDebugPlugin] Failed to parse event data:', e); + return null; + } + } + + // Helper function to parse page view data from request string + function parsePageViewData(request) { + if (!request) return null; + + try { + const params = new URLSearchParams(request); + + // Check if this is a page view (has url parameter but no e_c) + if (params.get('e_c') || !params.get('url')) return null; + + return { + url: decodeURIComponent(params.get('url') || ''), + title: params.get('action_name') ? decodeURIComponent(params.get('action_name')) : null, + visitorId: parseVisitorId(request), // From _id parameter: has value in cookie mode, null in anonymous mode + dimension1: params.get('dimension1') ? decodeURIComponent(params.get('dimension1')) : null, + dimension2: params.get('dimension2') ? decodeURIComponent(params.get('dimension2')) : null, + dimension3: params.get('dimension3') ? decodeURIComponent(params.get('dimension3')) : null, + referrer: params.get('urlref') ? decodeURIComponent(params.get('urlref')) : null, + timestamp: Date.now() + }; + } catch (e) { + console.warn('[Matomo Debug] Failed to parse page view data:', e); + return null; + } + } + + // Plugin registration function + function registerPlugin() { + if (!window.Matomo || typeof window.Matomo.addPlugin !== 'function') { + console.error('[MatomoDebugPlugin] Matomo not found or addPlugin not available'); + return false; + } + + try { + console.log('[MatomoDebugPlugin] Registering plugin with Matomo'); + window.Matomo.addPlugin('DebugPlugin', { + log: function () { + const data = window.__matomoDebugData; + data.pageViews.push({ + title: document.title, + url: window.location.href, + timestamp: Date.now() + }); + + return ''; + }, + + // This event function is called by Matomo when events are tracked + event: function () { + const args = Array.from(arguments); + console.log('[MatomoDebugPlugin] Captured event with args:', args); + + const data = window.__matomoDebugData; + + // Extract request string from first argument + let requestString = null; + if (args[0] && typeof args[0] === 'object' && args[0].request) { + requestString = args[0].request; + // Store the raw request for debugging + data.requests.push({ + request: requestString, + timestamp: Date.now(), + method: 'plugin_event', + url: requestString + }); + + // Parse event data from the request string + const eventData = parseEventData(requestString); + if (eventData) { + data.events.push(eventData); + } + + // Parse page view data + const pageViewData = parsePageViewData(requestString); + if (pageViewData) { + data.pageViews.push(pageViewData); + } + + // Parse visitor ID + const visitorId = parseVisitorId(requestString); + if (visitorId || (requestString && requestString.includes('_id='))) { + const match = requestString ? requestString.match(/[?&]_id=([^&]*)/) : null; + const actualVisitorId = match ? decodeURIComponent(match[1]) : null; + + data.visitorIds.push({ + visitorId: actualVisitorId, + isNull: !actualVisitorId || actualVisitorId === 'null' || actualVisitorId === '', + timestamp: Date.now() + }); + } + + // Parse dimensions + const dimensionMatches = requestString ? requestString.match(/[?&]dimension(\d+)=([^&]*)/g) : []; + if (dimensionMatches) { + dimensionMatches.forEach(match => { + const [, dimNum, dimValue] = match.match(/dimension(\d+)=([^&]*)/); + data.dimensions['dimension' + dimNum] = decodeURIComponent(dimValue); + }); + } + + } else { + // Store raw event data as fallback + data.events.push({ + timestamp: Date.now(), + method: 'plugin_event', + args: args, + category: 'unknown', + action: 'unknown', + raw_data: args + }); + } + + return ''; + } + }); + + return true; + } catch (e) { + console.error('[MatomoDebugPlugin] Failed to register plugin:', e); + return false; + } + } + + // Try to register immediately if Matomo is already loaded + if (window.Matomo && typeof window.Matomo.addPlugin === 'function') { + console.log('[MatomoDebugPlugin] Matomo already loaded, registering immediately'); + registerPlugin(); + } else { + // Register for Matomo's async plugin initialization as fallback + console.log('[MatomoDebugPlugin] Matomo not ready, queuing for async initialization'); + if (typeof window.matomoPluginAsyncInit === 'undefined') { + window.matomoPluginAsyncInit = []; + } + window.matomoPluginAsyncInit.push(registerPlugin); + } +} + +// Export for use in loader +if (typeof window !== 'undefined') { + window.initMatomoDebugPlugin = initMatomoDebugPlugin; +} \ No newline at end of file diff --git a/apps/remix-ide/src/blockchain/blockchain.tsx b/apps/remix-ide/src/blockchain/blockchain.tsx index 7b24aacd82a..58c3573341b 100644 --- a/apps/remix-ide/src/blockchain/blockchain.tsx +++ b/apps/remix-ide/src/blockchain/blockchain.tsx @@ -1,6 +1,7 @@ import React from 'react' // eslint-disable-line import { fromWei, toBigInt, toWei } from 'web3-utils' import { Plugin } from '@remixproject/engine' +import { trackMatomoEvent, BlockchainEvents, UdappEvents } from '@remix-api' import { toBytes, addHexPrefix } from '@ethereumjs/util' import { EventEmitter } from 'events' import { format } from 'util' @@ -19,8 +20,6 @@ const { txResultHelper } = helpers const { resultToRemixTx } = txResultHelper import * as packageJson from '../../../../package.json' -const _paq = (window._paq = window._paq || []) //eslint-disable-line - const profile = { name: 'blockchain', displayName: 'Blockchain', @@ -138,13 +137,13 @@ export class Blockchain extends Plugin { this.emit('shouldAddProvidertoUdapp', name, provider) this.pinnedProviders.push(name) this.call('config', 'setAppParameter', 'settings/pinned-providers', JSON.stringify(this.pinnedProviders)) - _paq.push(['trackEvent', 'blockchain', 'providerPinned', name]) + trackMatomoEvent(this, BlockchainEvents.providerPinned(name)) this.emit('providersChanged') }) // used to pin and select newly created forked state provider this.on('udapp', 'forkStateProviderAdded', (providerName) => { const name = `vm-fs-${providerName}` - _paq.push(['trackEvent', 'blockchain', 'providerPinned', name]) + trackMatomoEvent(this, BlockchainEvents.providerPinned(name)) this.emit('providersChanged') this.changeExecutionContext({ context: name }, null, null, null) this.call('notification', 'toast', `New environment '${providerName}' created with forked state.`) @@ -155,7 +154,7 @@ export class Blockchain extends Plugin { const index = this.pinnedProviders.indexOf(name) this.pinnedProviders.splice(index, 1) this.call('config', 'setAppParameter', 'settings/pinned-providers', JSON.stringify(this.pinnedProviders)) - _paq.push(['trackEvent', 'blockchain', 'providerUnpinned', name]) + trackMatomoEvent(this, BlockchainEvents.providerUnpinned(name)) this.emit('providersChanged') }) @@ -348,11 +347,11 @@ export class Blockchain extends Plugin { cancelLabel: 'Cancel', okFn: () => { this.runProxyTx(proxyData, implementationContractObject) - _paq.push(['trackEvent', 'blockchain', 'Deploy With Proxy', 'modal ok confirmation']) + trackMatomoEvent(this, BlockchainEvents.deployWithProxy('modal ok confirmation')) }, cancelFn: () => { this.call('notification', 'toast', cancelProxyMsg()) - _paq.push(['trackEvent', 'blockchain', 'Deploy With Proxy', 'cancel proxy deployment']) + trackMatomoEvent(this, BlockchainEvents.deployWithProxy('cancel proxy deployment')) }, hideFn: () => null } @@ -377,12 +376,12 @@ export class Blockchain extends Plugin { if (error) { const log = logBuilder(error) - _paq.push(['trackEvent', 'blockchain', 'Deploy With Proxy', 'Proxy deployment failed: ' + error]) + trackMatomoEvent(this, BlockchainEvents.deployWithProxy('Proxy deployment failed: ' + error)) return this.call('terminal', 'logHtml', log) } await this.saveDeployedContractStorageLayout(implementationContractObject, address, networkInfo) this.events.emit('newProxyDeployment', address, new Date().toISOString(), implementationContractObject.contractName) - _paq.push(['trackEvent', 'blockchain', 'Deploy With Proxy', 'Proxy deployment successful']) + trackMatomoEvent(this, BlockchainEvents.deployWithProxy('Proxy deployment successful')) this.call('udapp', 'addInstance', addressToString(address), implementationContractObject.abi, implementationContractObject.name, implementationContractObject) } @@ -399,11 +398,11 @@ export class Blockchain extends Plugin { cancelLabel: 'Cancel', okFn: () => { this.runUpgradeTx(proxyAddress, data, newImplementationContractObject) - _paq.push(['trackEvent', 'blockchain', 'Upgrade With Proxy', 'proxy upgrade confirmation click']) + trackMatomoEvent(this, BlockchainEvents.upgradeWithProxy('proxy upgrade confirmation click')) }, cancelFn: () => { this.call('notification', 'toast', cancelUpgradeMsg()) - _paq.push(['trackEvent', 'blockchain', 'Upgrade With Proxy', 'proxy upgrade cancel click']) + trackMatomoEvent(this, BlockchainEvents.upgradeWithProxy('proxy upgrade cancel click')) }, hideFn: () => null } @@ -428,11 +427,11 @@ export class Blockchain extends Plugin { if (error) { const log = logBuilder(error) - _paq.push(['trackEvent', 'blockchain', 'Upgrade With Proxy', 'Upgrade failed']) + trackMatomoEvent(this, BlockchainEvents.upgradeWithProxy('Upgrade failed')) return this.call('terminal', 'logHtml', log) } await this.saveDeployedContractStorageLayout(newImplementationContractObject, proxyAddress, networkInfo) - _paq.push(['trackEvent', 'blockchain', 'Upgrade With Proxy', 'Upgrade Successful']) + trackMatomoEvent(this, BlockchainEvents.upgradeWithProxy('Upgrade Successful')) this.call('udapp', 'addInstance', addressToString(proxyAddress), newImplementationContractObject.abi, newImplementationContractObject.name, newImplementationContractObject) } this.runTx(args, confirmationCb, continueCb, promptCb, finalCb) @@ -796,14 +795,18 @@ export class Blockchain extends Plugin { const logTransaction = (txhash, origin) => { this.detectNetwork((error, network) => { + const sendTransactionEvent = origin === 'plugin' + ? UdappEvents.sendTransactionFromPlugin + : UdappEvents.sendTransactionFromGui; + if (network && network.id) { - _paq.push(['trackEvent', 'udapp', `sendTransaction-from-${origin}`, `${txhash}-${network.id}`]) + trackMatomoEvent(this, sendTransactionEvent(`${txhash}-${network.id}`)) } else { try { const networkString = JSON.stringify(network) - _paq.push(['trackEvent', 'udapp', `sendTransaction-from-${origin}`, `${txhash}-${networkString}`]) + trackMatomoEvent(this, sendTransactionEvent(`${txhash}-${networkString}`)) } catch (e) { - _paq.push(['trackEvent', 'udapp', `sendTransaction-from-${origin}`, `${txhash}-unknownnetwork`]) + trackMatomoEvent(this, sendTransactionEvent(`${txhash}-unknownnetwork`)) } } }) @@ -814,7 +817,7 @@ export class Blockchain extends Plugin { }) web3Runner.event.register('transactionBroadcasted', (txhash, isUserOp) => { - if (isUserOp) _paq.push(['trackEvent', 'udapp', 'safeSmartAccount', `txBroadcastedFromSmartAccount`]) + if (isUserOp) trackMatomoEvent(this, UdappEvents.safeSmartAccount(`txBroadcastedFromSmartAccount`)) logTransaction(txhash, 'gui') this.executionContext.detectNetwork(async (error, network) => { if (error || !network) return @@ -1024,7 +1027,7 @@ export class Blockchain extends Plugin { if (!tx.timestamp) tx.timestamp = Date.now() const timestamp = tx.timestamp this._triggerEvent('initiatingTransaction', [timestamp, tx, payLoad]) - if (fromSmartAccount) _paq.push(['trackEvent', 'udapp', 'safeSmartAccount', `txInitiatedFromSmartAccount`]) + if (fromSmartAccount) trackMatomoEvent(this, UdappEvents.safeSmartAccount(`txInitiatedFromSmartAccount`)) try { this.txRunner.rawRun(tx, confirmationCb, continueCb, promptCb, async (error, result) => { if (error) { @@ -1088,7 +1091,7 @@ export class Blockchain extends Plugin { })} ) - _paq.push(['trackEvent', 'udapp', 'hardhat', 'console.log']) + trackMatomoEvent(this, UdappEvents.hardhat('console.log')) this.call('terminal', 'logHtml', finalLogs) } } diff --git a/apps/remix-ide/src/blockchain/execution-context.js b/apps/remix-ide/src/blockchain/execution-context.js index 9ee8988f197..fc5cd0e83f8 100644 --- a/apps/remix-ide/src/blockchain/execution-context.js +++ b/apps/remix-ide/src/blockchain/execution-context.js @@ -4,10 +4,22 @@ import { Web3 } from 'web3' import { execution } from '@remix-project/remix-lib' import EventManager from '../lib/events' import { bytesToHex } from '@ethereumjs/util' -const _paq = window._paq = window._paq || [] +import { UdappEvents } from '@remix-api' let web3 +// Helper function to track events using MatomoManager +function track(event) { + try { + const matomoManager = window._matomoManagerInstance + if (matomoManager && matomoManager.trackEvent) { + matomoManager.trackEvent(event) + } + } catch (error) { + console.debug('Tracking error:', error) + } +} + const config = { defaultTransactionType: '0x0' } if (typeof window !== 'undefined' && typeof window.ethereum !== 'undefined') { var injectedProvider = window.ethereum @@ -155,7 +167,7 @@ export class ExecutionContext { } async executionContextChange (value, endPointUrl, confirmCb, infoCb, cb) { - _paq.push(['trackEvent', 'udapp', 'providerChanged', value.context]) + track(UdappEvents.providerChanged(value.context)) const context = value.context if (!cb) cb = () => { /* Do nothing. */ } if (!confirmCb) confirmCb = () => { /* Do nothing. */ } diff --git a/apps/remix-ide/src/index.html b/apps/remix-ide/src/index.html index 7eb6776e5f2..2702f3af95f 100644 --- a/apps/remix-ide/src/index.html +++ b/apps/remix-ide/src/index.html @@ -36,7 +36,7 @@
- + diff --git a/apps/remix-ide/src/index.tsx b/apps/remix-ide/src/index.tsx index 6011fef095a..5e7708e6461 100644 --- a/apps/remix-ide/src/index.tsx +++ b/apps/remix-ide/src/index.tsx @@ -1,39 +1,31 @@ // eslint-disable-next-line no-use-before-define import React from 'react' import './index.css' -import { ThemeModule } from './app/tabs/theme-module' -import { LocaleModule } from './app/tabs/locale-module' -import { Preload } from './app/components/preload' -import { GitHubPopupCallback } from './app/pages/GitHubPopupCallback' -import Config from './config' -import { Registry } from '@remix-project/remix-lib' -import { Storage } from '@remix-project/remix-lib' - -import { createRoot } from 'react-dom/client' +import { MatomoManager } from './app/matomo/MatomoManager' +import { autoInitializeMatomo } from './app/matomo/MatomoAutoInit' +import { createMatomoConfig } from './app/matomo/MatomoConfig' +import { createTrackingFunction } from './app/utils/TrackingFunction' +import { setupThemeAndLocale } from './app/utils/AppSetup' +import { renderApp } from './app/utils/AppRenderer' ; (async function () { - try { - const configStorage = new Storage('config-v0.8:') - const config = new Config(configStorage) - Registry.getInstance().put({ api: config, name: 'config' }) - } catch (e) { } - const theme = new ThemeModule() - theme.initTheme() - const locale = new LocaleModule() - const settingsConfig = { themes: theme.getThemes(), locales: locale.getLocales() } + // Create Matomo configuration + const matomoConfig = createMatomoConfig(); + const matomoManager = new MatomoManager(matomoConfig); + window._matomoManagerInstance = matomoManager; + + // Setup config and auto-initialize Matomo if we have existing settings + await autoInitializeMatomo({ + matomoManager, + debug: true + }); + + // Setup theme and locale + setupThemeAndLocale(); - Registry.getInstance().put({ api: settingsConfig, name: 'settingsConfig' }) + // Create tracking function + const trackingFunction = createTrackingFunction(matomoManager); - const container = document.getElementById('root'); - const root = createRoot(container) - if (container) { - if (window.location.hash.includes('source=github')) { - root.render( - - ) - } else { - root.render( - ) - } - } + // Render the app + renderApp({ trackingFunction }); })() diff --git a/apps/remix-ide/src/remixAppManager.ts b/apps/remix-ide/src/remixAppManager.ts index 743209653f2..2d5d1771ee8 100644 --- a/apps/remix-ide/src/remixAppManager.ts +++ b/apps/remix-ide/src/remixAppManager.ts @@ -1,13 +1,12 @@ import { Plugin, PluginManager } from '@remixproject/engine' import { EventEmitter } from 'events' +import { trackMatomoEvent, PluginManagerEvents } from '@remix-api' import { QueryParams } from '@remix-project/remix-lib' import { IframePlugin } from '@remixproject/engine-web' import { Registry } from '@remix-project/remix-lib' import { RemixNavigator } from './types' import { Profile } from '@remixproject/plugin-utils' -const _paq = (window._paq = window._paq || []) - // requiredModule removes the plugin from the plugin manager list on UI let requiredModules = [ // services + layout views + system views @@ -261,7 +260,7 @@ export class RemixAppManager extends BaseRemixAppManager { ) this.event.emit('activate', plugin) this.emit('activate', plugin) - if (!this.isRequired(plugin.name)) _paq.push(['trackEvent', 'pluginManager', 'activate', plugin.name]) + if (!this.isRequired(plugin.name)) trackMatomoEvent(this, PluginManagerEvents.activate(plugin.name)) } getAll() { @@ -280,7 +279,7 @@ export class RemixAppManager extends BaseRemixAppManager { this.actives.filter((plugin) => !this.isDependent(plugin)) ) this.event.emit('deactivate', plugin) - _paq.push(['trackEvent', 'pluginManager', 'deactivate', plugin.name]) + trackMatomoEvent(this, PluginManagerEvents.deactivate(plugin.name)) } isDependent(name: string): boolean { diff --git a/apps/remixdesktop/src/global.d.ts b/apps/remixdesktop/src/global.d.ts new file mode 100644 index 00000000000..d637157580f --- /dev/null +++ b/apps/remixdesktop/src/global.d.ts @@ -0,0 +1,24 @@ +// Global type declarations for preload exposed electronAPI + +export {}; // ensure this file is treated as a module + +declare global { + interface Window { + electronAPI: { + isPackaged: () => Promise + isE2E: () => Promise + canTrackMatomo: () => Promise + // Desktop tracking helpers + trackDesktopEvent: (category: string, action: string, name?: string, value?: string | number) => Promise + setTrackingMode: (mode: 'cookie' | 'anon') => Promise + openFolder: (path: string) => Promise + openFolderInSameWindow: (path: string) => Promise + activatePlugin: (name: string) => Promise + plugins: Array<{ + name: string + on: (cb: (...args: any[]) => void) => void + send: (message: Partial) => void + }> + } + } +} diff --git a/libs/remix-ai-core/src/inferencers/local/ollama.ts b/libs/remix-ai-core/src/inferencers/local/ollama.ts index 605197ddef3..0702710aacb 100644 --- a/libs/remix-ai-core/src/inferencers/local/ollama.ts +++ b/libs/remix-ai-core/src/inferencers/local/ollama.ts @@ -1,6 +1,15 @@ import axios from 'axios'; -const _paq = (typeof window !== 'undefined' && (window as any)._paq) ? (window as any)._paq : [] +// Helper function to track events using MatomoManager instance +function trackMatomoEvent(category: string, action: string, name?: string) { + try { + if (typeof window !== 'undefined' && (window as any)._matomoManagerInstance) { + (window as any)._matomoManagerInstance.trackEvent(category, action, name) + } + } catch (error) { + // Silent fail for tracking + } +} // default Ollama ports to check (11434 is the legacy/standard port) const OLLAMA_PORTS = [11434, 11435, 11436]; @@ -10,42 +19,42 @@ let discoveredOllamaHost: string | null = null; export async function discoverOllamaHost(): Promise { if (discoveredOllamaHost) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_host_cache_hit', discoveredOllamaHost]); + trackMatomoEvent('ai', 'remixAI', `ollama_host_cache_hit:${discoveredOllamaHost}`); return discoveredOllamaHost; } for (const port of OLLAMA_PORTS) { const host = `${OLLAMA_BASE_HOST}:${port}`; - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_port_check', `${port}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_port_check:${port}`); try { const res = await axios.get(`${host}/api/tags`, { timeout: 2000 }); if (res.status === 200) { discoveredOllamaHost = host; - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_host_discovered_success', `${host}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_host_discovered_success:${host}`); return host; } } catch (error) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_port_connection_failed', `${port}:${error.message || 'unknown'}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_port_connection_failed:${port}:${error.message || 'unknown'}`); continue; // next port } } - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_host_discovery_failed', 'no_ports_available']); + trackMatomoEvent('ai', 'remixAI', 'ollama_host_discovery_failed:no_ports_available'); return null; } export async function isOllamaAvailable(): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_availability_check', 'checking']); + trackMatomoEvent('ai', 'remixAI', 'ollama_availability_check:checking'); const host = await discoverOllamaHost(); const isAvailable = host !== null; - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_availability_result', `available:${isAvailable}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_availability_result:available:${isAvailable}`); return isAvailable; } export async function listModels(): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_list_models_start', 'fetching']); + trackMatomoEvent('ai', 'remixAI', 'ollama_list_models_start:fetching'); const host = await discoverOllamaHost(); if (!host) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_list_models_failed', 'no_host']); + trackMatomoEvent('ai', 'remixAI', 'ollama_list_models_failed:no_host'); throw new Error('Ollama is not available'); } @@ -62,16 +71,16 @@ export function getOllamaHost(): string | null { } export function resetOllamaHost(): void { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_reset_host', discoveredOllamaHost || 'null']); + trackMatomoEvent('ai', 'remixAI', `ollama_reset_host:${discoveredOllamaHost || 'null'}`); discoveredOllamaHost = null; } export async function pullModel(modelName: string): Promise { // in case the user wants to pull a model from registry - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_pull_model_start', modelName]); + trackMatomoEvent('ai', 'remixAI', `ollama_pull_model_start:${modelName}`); const host = await discoverOllamaHost(); if (!host) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_pull_model_failed', `${modelName}|no_host`]); + trackMatomoEvent('ai', 'remixAI', `ollama_pull_model_failed:${modelName}|no_host`); throw new Error('Ollama is not available'); } @@ -79,9 +88,9 @@ export async function pullModel(modelName: string): Promise { const startTime = Date.now(); await axios.post(`${host}/api/pull`, { name: modelName }); const duration = Date.now() - startTime; - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_pull_model_success', `${modelName}|duration:${duration}ms`]); + trackMatomoEvent('ai', 'remixAI', `ollama_pull_model_success:${modelName}|duration:${duration}ms`); } catch (error) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_pull_model_error', `${modelName}|${error.message || 'unknown'}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_pull_model_error:${modelName}|${error.message || 'unknown'}`); console.error('Error pulling model:', error); throw new Error(`Failed to pull model: ${modelName}`); } @@ -97,7 +106,7 @@ export async function validateModel(modelName: string): Promise { } export async function getBestAvailableModel(): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_get_best']); + trackMatomoEvent('ai', 'remixAI', 'ollama_get_best'); try { const models = await listModels(); if (models.length === 0) return null; @@ -116,7 +125,7 @@ export async function getBestAvailableModel(): Promise { // TODO get model stats and get best model return models[0]; } catch (error) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_get_best_model_error', error.message || 'unknown']); + trackMatomoEvent('ai', 'remixAI', `ollama_get_best_model_error:${error.message || 'unknown'}`); console.error('Error getting best available model:', error); return null; } diff --git a/libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts b/libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts index 517e3e992ac..6340d68d561 100644 --- a/libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts +++ b/libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts @@ -19,7 +19,16 @@ import { import axios from "axios"; import { RemoteInferencer } from "../remote/remoteInference"; -const _paq = (typeof window !== 'undefined' && (window as any)._paq) ? (window as any)._paq : [] +// Helper function to track events using MatomoManager instance +function trackMatomoEvent(category: string, action: string, name?: string) { + try { + if (typeof window !== 'undefined' && (window as any)._matomoManagerInstance) { + (window as any)._matomoManagerInstance.trackEvent(category, action, name) + } + } catch (error) { + // Silent fail for tracking + } +} const defaultErrorMessage = `Unable to get a response from Ollama server`; export class OllamaInferencer extends RemoteInferencer implements ICompletions, IGeneration { @@ -42,29 +51,29 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, this.ollama_host = await discoverOllamaHost(); if (!this.ollama_host) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_initialize_failed', 'no_host_available']); + trackMatomoEvent('ai', 'remixAI', 'ollama_initialize_failed:no_host_available'); throw new Error('Ollama is not available on any of the default ports'); } - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_host_discovered', this.ollama_host]); + trackMatomoEvent('ai', 'remixAI', `ollama_host_discovered:${this.ollama_host}`); // Default to generate endpoint, will be overridden per request type this.api_url = `${this.ollama_host}/api/generate`; this.isInitialized = true; try { const availableModels = await listModels(); - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_models_found', availableModels.length.toString()]); + trackMatomoEvent('ai', 'remixAI', `ollama_models_found:${availableModels.length}`); if (availableModels.length > 0 && !availableModels.includes(this.model_name)) { // Prefer codestral model if available, otherwise use first available model const defaultModel = availableModels.find(m => m.includes('codestral')) || availableModels[0]; const wasCodestralSelected = defaultModel.includes('codestral'); this.model_name = defaultModel; - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_model_auto_selected', `${this.model_name}|codestral:${wasCodestralSelected}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_model_auto_selected:${this.model_name}|codestral:${wasCodestralSelected}`); } - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_initialize_success', this.model_name]); + trackMatomoEvent('ai', 'remixAI', `ollama_initialize_success:${this.model_name}`); } catch (error) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_model_selection_error', error.message || 'unknown_error']); + trackMatomoEvent('ai', 'remixAI', `ollama_model_selection_error:${error.message || 'unknown_error'}`); console.warn('Could not auto-select model. Make sure you have at least one model installed:', error); } } @@ -373,7 +382,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, if (hasNativeFIM) { // Native FIM support (prompt/suffix parameters) - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_fim_native', this.model_name]); + trackMatomoEvent('ai', 'remixAI', `ollama_fim_native:${this.model_name}`); payload = { model: this.model_name, prompt: prompt, @@ -382,7 +391,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, stop:options.stop }; } else if (hasTokenFIM) { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_fim_token_based', this.model_name]); + trackMatomoEvent('ai', 'remixAI', `ollama_fim_token_based:${this.model_name}`); const fimPrompt = this.fimManager.buildFIMPrompt(prompt, promptAfter, this.model_name); payload = { model: this.model_name, @@ -391,7 +400,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, stop:options.stop }; } else { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_completion_no_fim', this.model_name]); + trackMatomoEvent('ai', 'remixAI', `ollama_completion_no_fim:${this.model_name}`); const completionPrompt = await this.buildCompletionPrompt(prompt, promptAfter); payload = this._buildCompletionPayload(completionPrompt, CODE_COMPLETION_PROMPT); payload.stop = options.stop @@ -403,22 +412,22 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, if (result && this.currentSuffix) { const beforeLength = result.length; const cleaned = this.removeSuffixOverlap(result, this.currentSuffix); - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_suffix_overlap_removed', `before:${beforeLength}|after:${cleaned.length}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_suffix_overlap_removed:before:${beforeLength}|after:${cleaned.length}`); return cleaned; } - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_code_completion_complete', `length:${result?.length || 0}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_code_completion_complete:length:${result?.length || 0}`); return result; } async code_insertion(msg_pfx: string, msg_sfx: string, ctxFiles: any, fileName: any, options: IParams = GenerationParams): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_code_insertion', `model:${this.model_name}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_code_insertion:model:${this.model_name}`); // Delegate to code_completion which already handles suffix overlap removal return await this.code_completion(msg_pfx, msg_sfx, ctxFiles, fileName, options); } async code_generation(prompt: string, options: IParams = GenerationParams): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_code_generation', `model:${this.model_name}|stream:${!!options.stream_result}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_code_generation:model:${this.model_name}|stream:${!!options.stream_result}`); const payload = this._buildPayload(prompt, options, CODE_GENERATION_PROMPT); if (options.stream_result) { return await this._streamInferenceRequest(payload, AIRequestType.GENERAL); @@ -428,7 +437,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, } async generate(userPrompt: string, options: IParams = GenerationParams): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_generate_contract', `model:${this.model_name}|stream:${!!options.stream_result}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_generate_contract:model:${this.model_name}|stream:${!!options.stream_result}`); const payload = this._buildPayload(userPrompt, options, CONTRACT_PROMPT); if (options.stream_result) { return await this._streamInferenceRequest(payload, AIRequestType.GENERAL); @@ -438,7 +447,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, } async generateWorkspace(prompt: string, options: IParams = GenerationParams): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_generate_workspace', `model:${this.model_name}|stream:${!!options.stream_result}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_generate_workspace:model:${this.model_name}|stream:${!!options.stream_result}`); const payload = this._buildPayload(prompt, options, WORKSPACE_PROMPT); if (options.stream_result) { @@ -449,7 +458,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, } async answer(prompt: string, options: IParams = GenerationParams): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_chat_answer', `model:${this.model_name}|stream:${!!options.stream_result}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_chat_answer:model:${this.model_name}|stream:${!!options.stream_result}`); const chatHistory = buildChatPrompt() const payload = this._buildPayload(prompt, options, CHAT_PROMPT, chatHistory); if (options.stream_result) { @@ -460,7 +469,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, } async code_explaining(prompt: string, context: string = "", options: IParams = GenerationParams): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_code_explaining', `model:${this.model_name}|stream:${!!options.stream_result}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_code_explaining:model:${this.model_name}|stream:${!!options.stream_result}`); const payload = this._buildPayload(prompt, options, CODE_EXPLANATION_PROMPT); if (options.stream_result) { return await this._streamInferenceRequest(payload, AIRequestType.GENERAL); @@ -471,7 +480,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, } async error_explaining(prompt: string, options: IParams = GenerationParams): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_error_explaining', `model:${this.model_name}|stream:${!!options.stream_result}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_error_explaining:model:${this.model_name}|stream:${!!options.stream_result}`); const payload = this._buildPayload(prompt, options, ERROR_EXPLANATION_PROMPT); if (options.stream_result) { return await this._streamInferenceRequest(payload, AIRequestType.GENERAL); @@ -481,7 +490,7 @@ export class OllamaInferencer extends RemoteInferencer implements ICompletions, } async vulnerability_check(prompt: string, options: IParams = GenerationParams): Promise { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_vulnerability_check', `model:${this.model_name}|stream:${!!options.stream_result}`]); + trackMatomoEvent('ai', 'remixAI', `ollama_vulnerability_check:model:${this.model_name}|stream:${!!options.stream_result}`); const payload = this._buildPayload(prompt, options, SECURITY_ANALYSIS_PROMPT); if (options.stream_result) { return await this._streamInferenceRequest(payload, AIRequestType.GENERAL); diff --git a/libs/remix-api/src/index.ts b/libs/remix-api/src/index.ts index bd9f5012fe4..f9615df6d24 100644 --- a/libs/remix-api/src/index.ts +++ b/libs/remix-api/src/index.ts @@ -1,3 +1,6 @@ export * from './lib/remix-api' export * from './lib/types/git' -export * from './lib/types/desktopConnection' \ No newline at end of file +export * from './lib/types/desktopConnection' +export * from './lib/plugins/matomo-api' +export * from './lib/plugins/matomo-events' +export * from './lib/plugins/matomo-tracker' \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo-api.ts b/libs/remix-api/src/lib/plugins/matomo-api.ts index 9bd368f17ca..817b03c05bf 100644 --- a/libs/remix-api/src/lib/plugins/matomo-api.ts +++ b/libs/remix-api/src/lib/plugins/matomo-api.ts @@ -1,10 +1,110 @@ import { IFilePanel } from '@remixproject/plugin-api' import { StatusEvents } from '@remixproject/plugin-utils' +import { MatomoEvent } from './matomo-events' + +// Import types from MatomoManager +export type InitializationPattern = 'cookie-consent' | 'anonymous' | 'immediate' | 'no-consent'; + +export interface InitializationOptions { + trackingMode?: boolean; + timeout?: number; + [key: string]: any; +} + +export type TrackingMode = 'cookie' | 'anonymous'; + +export interface ModeSwitchOptions { + forgetConsent?: boolean; + deleteCookies?: boolean; + setDimension?: boolean; + processQueue?: boolean; + [key: string]: any; +} + +export interface MatomoCommand extends Array { + 0: string; // Command name +} + +export interface MatomoState { + initialized: boolean; + scriptLoaded: boolean; + currentMode: string | null; + consentGiven: boolean; + lastEventId: number; + loadingPromise: Promise | null; +} + +export interface MatomoStatus { + matomoLoaded: boolean; + paqLength: number; + paqType: 'array' | 'object' | 'undefined'; + cookieCount: number; + cookies: string[]; +} + +export interface MatomoDiagnostics { + config: any; + state: MatomoState; + status: MatomoStatus; + tracker: { + url: string; + siteId: number | string; + } | null; + userAgent: string; + timestamp: string; +} export interface IMatomoApi { - events:{ + events: { + 'matomo-initialized': (data: any) => void; + 'matomo-consent-changed': (data: any) => void; + 'matomo-mode-switched': (data: any) => void; } & StatusEvents methods: { - track: (data: string[]) => void + // Type-safe tracking method + track: (event: MatomoEvent) => void; + + // Direct access to full interface + getManager: () => any; + getMatomoManager: () => any; + + // Initialization methods + initialize: (pattern?: InitializationPattern, options?: InitializationOptions) => Promise; + loadScript: () => Promise; + waitForLoad: (timeout?: number) => Promise; + + // Mode switching & consent management + switchMode: (mode: TrackingMode, options?: ModeSwitchOptions) => Promise; + giveConsent: (options?: { processQueue?: boolean }) => Promise; + revokeConsent: () => Promise; + + // Tracking methods + trackEvent: (event: MatomoEvent) => number; + trackPageView: (title?: string) => void; + setCustomDimension: (id: number, value: string) => void; + + // State and status methods + getState: () => MatomoState & MatomoStatus; + getStatus: () => MatomoStatus; + isMatomoLoaded: () => boolean; + getMatomoCookies: () => string[]; + deleteMatomoCookies: () => Promise; + + // Queue management + getPreInitQueue: () => MatomoCommand[]; + getQueueStatus: () => { queueLength: number; initialized: boolean; commands: MatomoCommand[] }; + processPreInitQueue: () => Promise; + clearPreInitQueue: () => number; + + // Utility and diagnostic methods + testConsentBehavior: () => Promise; + getDiagnostics: () => MatomoDiagnostics; + inspectPaqArray: () => { length: number; contents: any[]; trackingCommands: any[] }; + batch: (commands: MatomoCommand[]) => void; + reset: () => Promise; + + // Event system (renamed to avoid Plugin conflicts) + addMatomoListener: (event: string, callback: (data: T) => void) => void; + removeMatomoListener: (event: string, callback: (data: T) => void) => void; } } diff --git a/libs/remix-api/src/lib/plugins/matomo-events.ts b/libs/remix-api/src/lib/plugins/matomo-events.ts new file mode 100644 index 00000000000..538be0e55e2 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo-events.ts @@ -0,0 +1,47 @@ +/** + * Matomo Analytics Events - Quick Reference + * + * @example Usage + * ```ts + * import { trackMatomoEvent, AIEvents, UdappEvents } from '@remix-api' + * + * trackMatomoEvent(plugin, AIEvents.remixAI('code_generation')) + * trackMatomoEvent(plugin, UdappEvents.DeployAndPublish('mainnet')) + * ``` + * + * @example Common Events + * ```ts + * // AI + * AIEvents.remixAI(), AIEvents.explainFunction() + * + * // Contracts + * UdappEvents.DeployAndPublish(), UdappEvents.sendTransactionFromGui() + * + * // Editor + * EditorEvents.save(), EditorEvents.format() + * + * // Files + * FileExplorerEvents.contextMenu(), WorkspaceEvents.create() + * ``` + * + * @example Add New Event + * ```ts + * // In ./matomo/events/[category]-events.ts: + * export interface MyEvent extends MatomoEventBase { + * category: 'myCategory' + * action: 'myAction' + * } + * + * export const MyEvents = { + * myAction: (name?: string): MyEvent => ({ + * category: 'myCategory', + * action: 'myAction', + * name, + * isClick: true + * }) + * } + * ``` + */ + +// Re-export everything from the modular system +export * from './matomo'; \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo-tracker.ts b/libs/remix-api/src/lib/plugins/matomo-tracker.ts new file mode 100644 index 00000000000..86885666cc0 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo-tracker.ts @@ -0,0 +1,144 @@ +/** + * Type-safe Matomo tracking helper utility + * + * This utility provides compile-time type safety for Matomo tracking calls + * by bypassing loose plugin API typing and enforcing MatomoEvent types. + * + * Usage: + * import { trackMatomoEvent } from '@remix-api'; + * + * // Instead of: plugin.call('matomo', 'trackEvent', 'category', 'action', 'name') + * trackMatomoEvent(plugin, HomeTabEvents.WORKSPACE_LOADED('workspaceName')); + * + * // Instead of: await api.call('matomo', 'trackEvent', 'ai', 'chat', 'user-input') + * await trackMatomoEvent(api, AIEvents.CHAT('user-input')); + */ + +import { MatomoEvent } from './matomo-events'; + +/** + * Type definition for any plugin-like object with a call method + */ +export interface PluginLike { + call: (pluginName: string, method: string, ...args: any[]) => any; +} + +/** + * Type-safe synchronous Matomo tracking function + * + * @param plugin - Any plugin-like object with a call method + * @param event - Type-safe MatomoEvent object with category, action, name, and value + */ +export function trackMatomoEvent(plugin: PluginLike, event: MatomoEvent): void { + if (!plugin || typeof plugin.call !== 'function') { + console.warn('trackMatomoEvent: Invalid plugin provided'); + return; + } + + if (!event || typeof event !== 'object' || !event.category || !event.action) { + console.warn('trackMatomoEvent: Invalid MatomoEvent provided', event); + return; + } + + // Use the plugin's call method but with type-safe parameters + plugin.call('matomo', 'trackEvent', event); +} + +/** + * Type-safe asynchronous Matomo tracking function + * + * @param plugin - Any plugin-like object with a call method + * @param event - Type-safe MatomoEvent object with category, action, name, and value + * @returns Promise that resolves when tracking is complete + */ +export async function trackMatomoEventAsync(plugin: PluginLike, event: MatomoEvent): Promise { + if (!plugin || typeof plugin.call !== 'function') { + console.warn('trackMatomoEventAsync: Invalid plugin provided'); + return; + } + + if (!event || typeof event !== 'object' || !event.category || !event.action) { + console.warn('trackMatomoEventAsync: Invalid MatomoEvent provided', event); + return; + } + + // Use the plugin's call method but with type-safe parameters + await plugin.call('matomo', 'trackEvent', event); +} + +/** + * Type-safe Matomo tracking class for stateful usage + * + * Useful when you want to maintain a reference to the plugin + * and make multiple tracking calls. + */ +export class MatomoTracker { + constructor(private plugin: PluginLike) { + if (!plugin || typeof plugin.call !== 'function') { + throw new Error('MatomoTracker: Invalid plugin provided'); + } + } + + /** + * Track a MatomoEvent synchronously + */ + track(event: MatomoEvent): void { + trackMatomoEvent(this.plugin, event); + } + + /** + * Track a MatomoEvent asynchronously + */ + async trackAsync(event: MatomoEvent): Promise { + await trackMatomoEventAsync(this.plugin, event); + } + + /** + * Create a scoped tracker for a specific event category + * This provides additional type safety by constraining to specific event builders + */ + createCategoryTracker MatomoEvent>>( + eventBuilders: T + ): CategoryTracker { + return new CategoryTracker(this.plugin, eventBuilders); + } +} + +/** + * Category-specific tracker that constrains to specific event builders + */ +export class CategoryTracker MatomoEvent>> { + constructor( + private plugin: PluginLike, + private eventBuilders: T + ) {} + + /** + * Track using a specific event builder method + */ + track( + builderMethod: K, + ...args: T[K] extends (...args: infer P) => any ? P : never + ): void { + const event = this.eventBuilders[builderMethod](...args); + trackMatomoEvent(this.plugin, event); + } + + /** + * Track using a specific event builder method asynchronously + */ + async trackAsync( + builderMethod: K, + ...args: T[K] extends (...args: infer P) => any ? P : never + ): Promise { + const event = this.eventBuilders[builderMethod](...args); + await trackMatomoEventAsync(this.plugin, event); + } +} + +/** + * Convenience function to create a MatomoTracker instance + */ +export function createMatomoTracker(plugin: PluginLike): MatomoTracker { + return new MatomoTracker(plugin); +} \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/core/base-types.ts b/libs/remix-api/src/lib/plugins/matomo/core/base-types.ts new file mode 100644 index 00000000000..15bf0c52f59 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/core/base-types.ts @@ -0,0 +1,14 @@ +/** + * Core Matomo Event Types and Interfaces + * + * This file contains the base types and interfaces used throughout the Matomo event system. + */ + +export interface MatomoEventBase { + name?: string; + value?: string | number; + isClick?: boolean; // Pre-defined by event builders - distinguishes click events from other interactions +} + +// Note: The MatomoEvent union type will be built up by importing from individual event files +// in the main index.ts file to avoid circular dependencies \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/core/categories.ts b/libs/remix-api/src/lib/plugins/matomo/core/categories.ts new file mode 100644 index 00000000000..f66573fd2b9 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/core/categories.ts @@ -0,0 +1,46 @@ +/** + * Matomo Category Constants + * + * Single source of truth for all Matomo event categories and actions. + * These are used for type-safe event creation. + */ + +// Type-Safe Constants - Access categories and actions via types instead of string literals +export const MatomoCategories = { + FILE_EXPLORER: 'fileExplorer' as const, + COMPILER: 'compiler' as const, + HOME_TAB: 'hometab' as const, + AI: 'AI' as const, + UDAPP: 'udapp' as const, + GIT: 'git' as const, + WORKSPACE: 'workspace' as const, + XTERM: 'xterm' as const, + LAYOUT: 'layout' as const, + REMIX_AI: 'remixAI' as const, + SETTINGS: 'settings' as const, + SOLIDITY: 'solidity' as const, + CONTRACT_VERIFICATION: 'ContractVerification' as const, + CIRCUIT_COMPILER: 'circuit-compiler' as const, + LEARNETH: 'learneth' as const, + REMIX_GUIDE: 'remixGuide' as const, + TEMPLATE_SELECTION: 'template-selection' as const, + SOLIDITY_UML_GEN: 'solidityumlgen' as const, + SOLIDITY_SCRIPT: 'SolidityScript' as const, + SCRIPT_EXECUTOR: 'ScriptExecutor' as const, + LOCALE_MODULE: 'localeModule' as const, + THEME_MODULE: 'themeModule' as const +} + +// Common action constants used across multiple categories +export const FileExplorerActions = { + CONTEXT_MENU: 'contextMenu' as const, + WORKSPACE_MENU: 'workspaceMenu' as const, + FILE_ACTION: 'fileAction' as const, + DRAG_DROP: 'dragDrop' as const +} + +export const CompilerActions = { + COMPILED: 'compiled' as const, + ERROR: 'error' as const, + WARNING: 'warning' as const +} \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/events/ai-events.ts b/libs/remix-api/src/lib/plugins/matomo/events/ai-events.ts new file mode 100644 index 00000000000..33cb9aa5748 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/events/ai-events.ts @@ -0,0 +1,295 @@ +/** + * AI Events - AI and Copilot related tracking events + * + * This file contains all AI-related Matomo events including RemixAI interactions, + * Ollama local AI, and code completion features. + */ + +import { MatomoEventBase } from '../core/base-types'; + +export interface AIEvent extends MatomoEventBase { + category: 'ai'; + action: + | 'remixAI' + | 'error_explaining_SolidityError' + | 'vulnerability_check_pasted_code' + | 'generateDocumentation' + | 'explainFunction' + | 'Copilot_Completion_Accepted' + | 'code_generation' + | 'code_insertion' + | 'code_completion' + | 'AddingAIContext' + | 'ollama_host_cache_hit' + | 'ollama_port_check' + | 'ollama_host_discovered_success' + | 'ollama_port_connection_failed' + | 'ollama_host_discovery_failed' + | 'ollama_availability_check' + | 'ollama_availability_result' + | 'ollama_list_models_start' + | 'ollama_list_models_failed' + | 'ollama_reset_host' + | 'ollama_pull_model_start' + | 'ollama_pull_model_failed' + | 'ollama_pull_model_success' + | 'ollama_pull_model_error' + | 'ollama_get_best' + | 'ollama_get_best_model_error' + | 'ollama_initialize_failed' + | 'ollama_host_discovered' + | 'ollama_models_found' + | 'ollama_model_auto_selected' + | 'ollama_initialize_success' + | 'ollama_model_selection_error' + | 'ollama_fim_native' + | 'ollama_fim_token_based' + | 'ollama_completion_no_fim' + | 'ollama_suffix_overlap_removed' + | 'ollama_code_completion_complete' + | 'ollama_code_insertion' + | 'ollama_generate_contract' + | 'ollama_generate_workspace' + | 'ollama_chat_answer' + | 'ollama_code_explaining' + | 'ollama_error_explaining' + | 'ollama_vulnerability_check' + | 'ollama_provider_selected' + | 'ollama_fallback_to_provider' + | 'ollama_default_model_selected' + | 'ollama_unavailable' + | 'ollama_connection_error' + | 'ollama_model_selected' + | 'ollama_model_set_backend_success' + | 'ollama_model_set_backend_failed'; +} + +/** + * AI Events - Type-safe builders + */ +export const AIEvents = { + remixAI: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'remixAI', + name, + value, + isClick: true // User clicks to interact with RemixAI + }), + + explainFunction: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'explainFunction', + name, + value, + isClick: true // User clicks to request function explanation from AI + }), + + generateDocumentation: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'generateDocumentation', + name, + value, + isClick: true // User clicks to request AI documentation generation + }), + + vulnerabilityCheckPastedCode: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'vulnerability_check_pasted_code', + name, + value, + isClick: true // User requests AI vulnerability check on pasted code + }), + + copilotCompletionAccepted: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'Copilot_Completion_Accepted', + name, + value, + isClick: true // User accepts AI copilot completion + }), + + codeGeneration: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'code_generation', + name, + value, + isClick: false // AI generates code automatically + }), + + codeInsertion: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'code_insertion', + name, + value, + isClick: false // AI inserts code automatically + }), + + codeCompletion: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'code_completion', + name, + value, + isClick: false // AI completes code automatically + }), + + AddingAIContext: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'AddingAIContext', + name, + value, + isClick: true // User adds AI context + }), + + ollamaProviderSelected: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'ollama_provider_selected', + name, + value, + isClick: false // System selects provider + }), + + ollamaModelSelected: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'ollama_model_selected', + name, + value, + isClick: true // User selects model + }), + + ollamaUnavailable: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'ollama_unavailable', + name, + value, + isClick: false // System detects unavailability + }), + + ollamaConnectionError: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'ollama_connection_error', + name, + value, + isClick: false // System connection error + }), + + ollamaFallbackToProvider: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'ollama_fallback_to_provider', + name, + value, + isClick: false // System falls back to provider + }), + + ollamaDefaultModelSelected: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'ollama_default_model_selected', + name, + value, + isClick: false // System selects default model + }), + + ollamaModelSetBackendSuccess: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'ollama_model_set_backend_success', + name, + value, + isClick: false // Backend successfully set model + }), + + ollamaModelSetBackendFailed: (name?: string, value?: string | number): AIEvent => ({ + category: 'ai', + action: 'ollama_model_set_backend_failed', + name, + value, + isClick: false // Backend failed to set model + }) +} as const; + +/** + * RemixAI Events - Specific to RemixAI interactions + */ +export interface RemixAIEvent extends MatomoEventBase { + category: 'remixAI'; + action: + | 'ModeSwitch' + | 'GenerateNewAIWorkspaceFromEditMode' + | 'SetAIProvider' + | 'SetOllamaModel' + | 'GenerateNewAIWorkspaceFromModal'; +} + +/** + * RemixAI Events - Type-safe builders + */ +export const RemixAIEvents = { + ModeSwitch: (name?: string, value?: string | number): RemixAIEvent => ({ + category: 'remixAI', + action: 'ModeSwitch', + name, + value, + isClick: true // User switches AI mode + }), + + GenerateNewAIWorkspaceFromEditMode: (name?: string, value?: string | number): RemixAIEvent => ({ + category: 'remixAI', + action: 'GenerateNewAIWorkspaceFromEditMode', + name, + value, + isClick: true // User generates workspace from edit mode + }), + + SetAIProvider: (name?: string, value?: string | number): RemixAIEvent => ({ + category: 'remixAI', + action: 'SetAIProvider', + name, + value, + isClick: true // User sets AI provider + }), + + SetOllamaModel: (name?: string, value?: string | number): RemixAIEvent => ({ + category: 'remixAI', + action: 'SetOllamaModel', + name, + value, + isClick: true // User sets Ollama model + }), + + GenerateNewAIWorkspaceFromModal: (name?: string, value?: string | number): RemixAIEvent => ({ + category: 'remixAI', + action: 'GenerateNewAIWorkspaceFromModal', + name, + value, + isClick: true // User generates workspace from modal + }) +} as const; + +/** + * RemixAI Assistant Events - Specific to assistant interactions + */ +export interface RemixAIAssistantEvent extends MatomoEventBase { + category: 'remixAIAssistant'; + action: + | 'likeResponse' + | 'dislikeResponse'; +} + +/** + * RemixAI Assistant Events - Type-safe builders + */ +export const RemixAIAssistantEvents = { + likeResponse: (name?: string, value?: string | number): RemixAIAssistantEvent => ({ + category: 'remixAIAssistant', + action: 'likeResponse', + name, + value, + isClick: true // User likes AI response + }), + + dislikeResponse: (name?: string, value?: string | number): RemixAIAssistantEvent => ({ + category: 'remixAIAssistant', + action: 'dislikeResponse', + name, + value, + isClick: true // User dislikes AI response + }) +} as const; \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/events/blockchain-events.ts b/libs/remix-api/src/lib/plugins/matomo/events/blockchain-events.ts new file mode 100644 index 00000000000..5f510523506 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/events/blockchain-events.ts @@ -0,0 +1,275 @@ +/** + * Blockchain Events - Blockchain interactions and UDAPP tracking events + * + * This file contains all blockchain and universal dapp related Matomo events. + */ + +import { MatomoEventBase } from '../core/base-types'; + +export interface BlockchainEvent extends MatomoEventBase { + category: 'blockchain'; + action: + | 'providerChanged' + | 'accountChanged' + | 'connectionError' + | 'transactionFailed' + | 'providerPinned' + | 'providerUnpinned' + | 'deployWithProxy' + | 'upgradeWithProxy'; +} + +export interface UdappEvent extends MatomoEventBase { + category: 'udapp'; + action: + | 'providerChanged' + | 'sendTransaction-from-plugin' + | 'sendTransaction-from-gui' + | 'safeSmartAccount' + | 'hardhat' + | 'sendTx' + | 'call' + | 'lowLevelinteractions' + | 'transact' + | 'syncContracts' + | 'forkState' + | 'deleteState' + | 'pinContracts' + | 'signUsingAccount' + | 'contractDelegation' + | 'useAtAddress' + | 'DeployAndPublish' + | 'DeployOnly' + | 'DeployContractTo' + | 'broadcastCompilationResult'; +} + +export interface RunEvent extends MatomoEventBase { + category: 'run'; + action: + | 'recorder' + | 'debug'; +} + +/** + * Blockchain Events - Type-safe builders + */ +export const BlockchainEvents = { + providerChanged: (name?: string, value?: string | number): BlockchainEvent => ({ + category: 'blockchain', + action: 'providerChanged', + name, + value, + isClick: true // User clicks to change provider + }), + + providerPinned: (name?: string, value?: string | number): BlockchainEvent => ({ + category: 'blockchain', + action: 'providerPinned', + name, + value, + isClick: true // User pins a provider + }), + + providerUnpinned: (name?: string, value?: string | number): BlockchainEvent => ({ + category: 'blockchain', + action: 'providerUnpinned', + name, + value, + isClick: true // User unpins a provider + }), + + deployWithProxy: (name?: string, value?: string | number): BlockchainEvent => ({ + category: 'blockchain', + action: 'deployWithProxy', + name, + value, + isClick: true // User deploys contract with proxy + }), + + upgradeWithProxy: (name?: string, value?: string | number): BlockchainEvent => ({ + category: 'blockchain', + action: 'upgradeWithProxy', + name, + value, + isClick: true // User upgrades contract with proxy + }) +} as const; + +/** + * Udapp Events - Type-safe builders + */ +export const UdappEvents = { + providerChanged: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'providerChanged', + name, + value, + isClick: true // User clicks to change provider + }), + + sendTransactionFromPlugin: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'sendTransaction-from-plugin', + name, + value, + isClick: true // User clicks to send transaction from plugin + }), + + sendTransactionFromGui: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'sendTransaction-from-gui', + name, + value, + isClick: true // User clicks to send transaction from GUI + }), + + hardhat: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'hardhat', + name, + value, + isClick: true // User clicks Hardhat-related actions + }), + + sendTx: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'sendTx', + name, + value, + isClick: true // User clicks to send transaction + }), + + call: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'call', + name, + value, + isClick: true // User calls a view/pure function + }), + + lowLevelinteractions: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'lowLevelinteractions', + name, + value, + isClick: true // User interacts with fallback/receive functions + }), + + transact: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'transact', + name, + value, + isClick: true // User executes a state-changing function + }), + + syncContracts: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'syncContracts', + name, + value, + isClick: true // User clicks to sync contracts + }), + + pinContracts: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'pinContracts', + name, + value, + isClick: true // User clicks to pin/unpin contracts + }), + + safeSmartAccount: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'safeSmartAccount', + name, + value, + isClick: true // User interacts with Safe Smart Account features + }), + + contractDelegation: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'contractDelegation', + name, + value, + isClick: true // User interacts with contract delegation + }), + + signUsingAccount: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'signUsingAccount', + name, + value, + isClick: false // Signing action is typically system-triggered + }), + + forkState: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'forkState', + name, + value, + isClick: true // User clicks to fork state + }), + + deleteState: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'deleteState', + name, + value, + isClick: true // User clicks to delete state + }), + + useAtAddress: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'useAtAddress', + name, + value, + isClick: true // User uses existing contract at address + }), + + DeployAndPublish: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'DeployAndPublish', + name, + value, + isClick: true // User clicks to deploy and publish + }), + + DeployOnly: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'DeployOnly', + name, + value, + isClick: true // User clicks to deploy only + }), + + deployContractTo: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'DeployContractTo', + name, + value, + isClick: true // User deploys contract to specific address + }), + + broadcastCompilationResult: (name?: string, value?: string | number): UdappEvent => ({ + category: 'udapp', + action: 'broadcastCompilationResult', + name, + value, + isClick: false // System broadcasts compilation result + }) +} as const; + +/** + * Run Events - Type-safe builders + */ +export const RunEvents = { + recorder: (name?: string, value?: string | number): RunEvent => ({ + category: 'run', + action: 'recorder', + name, + value, + isClick: true // User interacts with recorder functionality + }) +} as const; \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/events/compiler-events.ts b/libs/remix-api/src/lib/plugins/matomo/events/compiler-events.ts new file mode 100644 index 00000000000..46fe16101b4 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/events/compiler-events.ts @@ -0,0 +1,208 @@ +/** + * Compiler Events - Solidity compilation and related tracking events + * + * This file contains all compilation-related Matomo events. + */ + +import { MatomoEventBase } from '../core/base-types'; + +export interface CompilerEvent extends MatomoEventBase { + category: 'compiler'; + action: + | 'compiled' + | 'compilerDetails'; +} + +export interface SolidityCompilerEvent extends MatomoEventBase { + category: 'solidityCompiler'; + action: + | 'runStaticAnalysis' + | 'solidityScan' + | 'staticAnalysis' + | 'initiate'; +} + +export interface CompilerContainerEvent extends MatomoEventBase { + category: 'compilerContainer'; + action: + | 'compile' + | 'compileAndRun' + | 'autoCompile' + | 'includeNightlies' + | 'hideWarnings' + | 'optimization' + | 'useConfigurationFile' + | 'compilerSelection' + | 'languageSelection' + | 'evmVersionSelection' + | 'addCustomCompiler' + | 'viewLicense' + | 'advancedConfigToggle'; +} + +/** + * Compiler Events - Type-safe builders + */ +export const CompilerEvents = { + compiled: (name?: string, value?: string | number): CompilerEvent => ({ + category: 'compiler', + action: 'compiled', + name, + value, + isClick: false // Compilation is typically a system event + }), + + compilerDetails: (name?: string, value?: string | number): CompilerEvent => ({ + category: 'compiler', + action: 'compilerDetails', + name, + value, + isClick: true // User clicks to view/download compiler details + }) +} as const; + +/** + * Solidity Compiler Events - Type-safe builders + */ +export const SolidityCompilerEvents = { + runStaticAnalysis: (name?: string, value?: string | number): SolidityCompilerEvent => ({ + category: 'solidityCompiler', + action: 'runStaticAnalysis', + name, + value, + isClick: true // User clicks to run static analysis + }), + + solidityScan: (name?: string, value?: string | number): SolidityCompilerEvent => ({ + category: 'solidityCompiler', + action: 'solidityScan', + name, + value, + isClick: true // User interacts with Solidity scan features + }), + + staticAnalysis: (name?: string, value?: string | number): SolidityCompilerEvent => ({ + category: 'solidityCompiler', + action: 'staticAnalysis', + name, + value, + isClick: false // Analysis completion is a system event + }), + + initiate: (name?: string, value?: string | number): SolidityCompilerEvent => ({ + category: 'solidityCompiler', + action: 'initiate', + name, + value, + isClick: false // System initialization event + }) +} as const; + +/** + * Compiler Container Events - Type-safe builders for UI interactions + */ +export const CompilerContainerEvents = { + compile: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'compile', + name, + value, + isClick: true // User clicks compile button + }), + + compileAndRun: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'compileAndRun', + name, + value, + isClick: true // User clicks compile and run button + }), + + autoCompile: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'autoCompile', + name, + value, + isClick: true // User toggles auto-compile checkbox + }), + + includeNightlies: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'includeNightlies', + name, + value, + isClick: true // User toggles include nightly builds checkbox + }), + + hideWarnings: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'hideWarnings', + name, + value, + isClick: true // User toggles hide warnings checkbox + }), + + optimization: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'optimization', + name, + value, + isClick: true // User changes optimization settings + }), + + useConfigurationFile: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'useConfigurationFile', + name, + value, + isClick: true // User toggles use configuration file checkbox + }), + + compilerSelection: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'compilerSelection', + name, + value, + isClick: true // User selects different compiler version + }), + + languageSelection: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'languageSelection', + name, + value, + isClick: true // User changes language (Solidity/Yul) + }), + + evmVersionSelection: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'evmVersionSelection', + name, + value, + isClick: true // User selects EVM version + }), + + addCustomCompiler: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'addCustomCompiler', + name, + value, + isClick: true // User clicks to add custom compiler + }), + + viewLicense: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'viewLicense', + name, + value, + isClick: true // User clicks to view compiler license + }), + + advancedConfigToggle: (name?: string, value?: string | number): CompilerContainerEvent => ({ + category: 'compilerContainer', + action: 'advancedConfigToggle', + name, + value, + isClick: true // User toggles advanced configurations section + }) +} as const; \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/events/file-events.ts b/libs/remix-api/src/lib/plugins/matomo/events/file-events.ts new file mode 100644 index 00000000000..ec37bc48feb --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/events/file-events.ts @@ -0,0 +1,218 @@ +/** + * File Events - File explorer and workspace management tracking events + * + * This file contains all file management related Matomo events. + */ + +import { MatomoEventBase } from '../core/base-types'; + +export interface FileExplorerEvent extends MatomoEventBase { + category: 'fileExplorer'; + action: + | 'contextMenu' + | 'workspaceMenu' + | 'fileAction' + | 'deleteKey' + | 'osxDeleteKey' + | 'f2ToRename' + | 'copyCombo' + | 'cutCombo' + | 'pasteCombo'; +} + +export interface WorkspaceEvent extends MatomoEventBase { + category: 'Workspace'; + action: + | 'switchWorkspace' + | 'GIT' + | 'createWorkspace'; +} + +export interface StorageEvent extends MatomoEventBase { + category: 'Storage'; + action: + | 'activate' + | 'error'; +} + +export interface BackupEvent extends MatomoEventBase { + category: 'Backup'; + action: + | 'create' + | 'restore' + | 'error' + | 'download' + | 'userActivate'; +} + +/** + * File Explorer Events - Type-safe builders + */ +export const FileExplorerEvents = { + contextMenu: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'contextMenu', + name, + value, + isClick: true // Context menu selections are click interactions + }), + + workspaceMenu: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'workspaceMenu', + name, + value, + isClick: true // Workspace menu selections are click interactions + }), + + fileAction: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'fileAction', + name, + value, + isClick: true // File actions like double-click to open are click interactions + }), + + deleteKey: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'deleteKey', + name, + value, + isClick: false // Keyboard delete key is not a click interaction + }), + + osxDeleteKey: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'osxDeleteKey', + name, + value, + isClick: false // macOS delete key is not a click interaction + }), + + f2ToRename: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'f2ToRename', + name, + value, + isClick: false // F2 key to rename is not a click interaction + }), + + copyCombo: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'copyCombo', + name, + value, + isClick: false // Ctrl+C/Cmd+C keyboard shortcut is not a click interaction + }), + + cutCombo: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'cutCombo', + name, + value, + isClick: false // Ctrl+X/Cmd+X keyboard shortcut is not a click interaction + }), + + pasteCombo: (name?: string, value?: string | number): FileExplorerEvent => ({ + category: 'fileExplorer', + action: 'pasteCombo', + name, + value, + isClick: false // Ctrl+V/Cmd+V keyboard shortcut is not a click interaction + }) +} as const; + +/** + * Workspace Events - Type-safe builders + */ +export const WorkspaceEvents = { + switchWorkspace: (name?: string, value?: string | number): WorkspaceEvent => ({ + category: 'Workspace', + action: 'switchWorkspace', + name, + value, + isClick: true // User clicks to switch workspace + }), + + GIT: (name?: string, value?: string | number): WorkspaceEvent => ({ + category: 'Workspace', + action: 'GIT', + name, + value, + isClick: true // User clicks Git-related actions in workspace + }), + + createWorkspace: (name?: string, value?: string | number): WorkspaceEvent => ({ + category: 'Workspace', + action: 'createWorkspace', + name, + value, + isClick: true // User clicks to create new workspace + }) +} as const; + +/** + * Storage Events - Type-safe builders + */ +export const StorageEvents = { + activate: (name?: string, value?: string | number): StorageEvent => ({ + category: 'Storage', + action: 'activate', + name, + value, + isClick: false // Storage activation is typically a system event + }), + + error: (name?: string, value?: string | number): StorageEvent => ({ + category: 'Storage', + action: 'error', + name, + value, + isClick: false // Storage errors are system events + }) +} as const; + +/** + * Backup Events - Type-safe builders + */ +export const BackupEvents = { + create: (name?: string, value?: string | number): BackupEvent => ({ + category: 'Backup', + action: 'create', + name, + value, + isClick: true // User initiates backup + }), + + restore: (name?: string, value?: string | number): BackupEvent => ({ + category: 'Backup', + action: 'restore', + name, + value, + isClick: true // User initiates restore + }), + + error: (name?: string, value?: string | number): BackupEvent => ({ + category: 'Backup', + action: 'error', + name, + value, + isClick: false // Backup errors are system events + }), + + download: (name?: string, value?: string | number): BackupEvent => ({ + category: 'Backup', + action: 'download', + name, + value, + isClick: true // User downloads backup + }), + + userActivate: (name?: string, value?: string | number): BackupEvent => ({ + category: 'Backup', + action: 'userActivate', + name, + value, + isClick: true // User activates backup feature + }) +} as const; \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/events/git-events.ts b/libs/remix-api/src/lib/plugins/matomo/events/git-events.ts new file mode 100644 index 00000000000..0ddcb775d97 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/events/git-events.ts @@ -0,0 +1,98 @@ +/** + * Git Events - Git integration and version control tracking events + * + * This file contains all Git-related Matomo events. + */ + +import { MatomoEventBase } from '../core/base-types'; + +export interface GitEvent extends MatomoEventBase { + category: 'git'; + action: + | 'INIT' + | 'COMMIT' + | 'PUSH' + | 'PULL' + | 'CLONE' + | 'CHECKOUT' + | 'BRANCH' + | 'OPEN_PANEL' + | 'CONNECT_TO_GITHUB'; +} + +/** + * Git Events - Type-safe builders + */ +export const GitEvents = { + INIT: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'INIT', + name, + value, + isClick: true // User clicks to initialize git + }), + + COMMIT: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'COMMIT', + name, + value, + isClick: true // User clicks to commit changes + }), + + PUSH: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'PUSH', + name, + value, + isClick: true // User clicks to push changes + }), + + PULL: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'PULL', + name, + value, + isClick: true // User clicks to pull changes + }), + + CLONE: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'CLONE', + name, + value, + isClick: true // User clicks to clone repository + }), + + CHECKOUT: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'CHECKOUT', + name, + value, + isClick: true // User clicks to checkout branch + }), + + BRANCH: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'BRANCH', + name, + value, + isClick: true // User clicks branch-related actions + }), + + OPEN_PANEL: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'OPEN_PANEL', + name, + value, + isClick: true // User clicks to open git panel + }), + + CONNECT_TO_GITHUB: (name?: string, value?: string | number): GitEvent => ({ + category: 'git', + action: 'CONNECT_TO_GITHUB', + name, + value, + isClick: true // User clicks to connect to GitHub + }) +} as const; \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/events/plugin-events.ts b/libs/remix-api/src/lib/plugins/matomo/events/plugin-events.ts new file mode 100644 index 00000000000..b01e4af52d5 --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/events/plugin-events.ts @@ -0,0 +1,349 @@ +/** + * Plugin Events - Plugin management and interaction tracking events + * + * This file contains all plugin-related Matomo events. + */ + +import { MatomoEventBase } from '../core/base-types'; + +export interface PluginEvent extends MatomoEventBase { + category: 'plugin'; + action: + | 'activate' + | 'activated' + | 'deactivate' + | 'install' + | 'error' + | 'contractFlattener'; +} + +export interface ManagerEvent extends MatomoEventBase { + category: 'manager'; + action: + | 'activate' + | 'deactivate' + | 'toggle'; +} + +export interface PluginManagerEvent extends MatomoEventBase { + category: 'pluginManager'; + action: + | 'activate' + | 'deactivate'; +} + +export interface PluginPanelEvent extends MatomoEventBase { + category: 'pluginPanel'; + action: + | 'toggle' + | 'open' + | 'close' + | 'pinToRight' + | 'pinToLeft'; +} + +export interface AppEvent extends MatomoEventBase { + category: 'App'; + action: + | 'queryParams-activated' + | 'loaded' + | 'error' + | 'PreloadError' + | 'queryParamsCalls'; +} + +export interface MigrateEvent extends MatomoEventBase { + category: 'migrate'; + action: + | 'start' + | 'complete' + | 'error' + | 'result'; +} + +export interface MatomoEvent_Core extends MatomoEventBase { + category: 'Matomo'; + action: + | 'showConsentDialog' + | 'consentAccepted' + | 'consentRejected' + | 'trackingEnabled' + | 'trackingDisabled'; +} + +export interface MatomoManagerEvent extends MatomoEventBase { + category: 'MatomoManager'; + action: + | 'initialize' + | 'switchMode' + | 'trackEvent' + | 'error' + | 'showConsentDialog'; +} + +/** + * Plugin Events - Type-safe builders + */ +export const PluginEvents = { + activate: (name?: string, value?: string | number): PluginEvent => ({ + category: 'plugin', + action: 'activate', + name, + value, + isClick: true // User activates plugin + }), + + deactivate: (name?: string, value?: string | number): PluginEvent => ({ + category: 'plugin', + action: 'deactivate', + name, + value, + isClick: true // User deactivates plugin + }), + + install: (name?: string, value?: string | number): PluginEvent => ({ + category: 'plugin', + action: 'install', + name, + value, + isClick: true // User installs plugin + }), + + error: (name?: string, value?: string | number): PluginEvent => ({ + category: 'plugin', + action: 'error', + name, + value, + isClick: false // Plugin errors are system events + }), + + activated: (name?: string, value?: string | number): PluginEvent => ({ + category: 'plugin', + action: 'activated', + name, + value, + isClick: true // Plugin activated (same as activate, for compatibility) + }), + + contractFlattener: (name?: string, value?: string | number): PluginEvent => ({ + category: 'plugin', + action: 'contractFlattener', + name, + value, + isClick: true // User interacts with contract flattener functionality + }) +} as const; + +/** + * Manager Events - Type-safe builders + */ +export const ManagerEvents = { + activate: (name?: string, value?: string | number): ManagerEvent => ({ + category: 'manager', + action: 'activate', + name, + value, + isClick: true // User activates plugin through manager + }), + + deactivate: (name?: string, value?: string | number): ManagerEvent => ({ + category: 'manager', + action: 'deactivate', + name, + value, + isClick: true // User deactivates plugin through manager + }), + + toggle: (name?: string, value?: string | number): ManagerEvent => ({ + category: 'manager', + action: 'toggle', + name, + value, + isClick: true // User toggles plugin state + }) +} as const; + +/** + * Plugin Manager Events - Type-safe builders + */ +export const PluginManagerEvents = { + activate: (name?: string, value?: string | number): PluginManagerEvent => ({ + category: 'pluginManager', + action: 'activate', + name, + value, + isClick: true // User activates plugin + }), + + deactivate: (name?: string, value?: string | number): PluginManagerEvent => ({ + category: 'pluginManager', + action: 'deactivate', + name, + value, + isClick: true // User deactivates plugin + }) +} as const; + +/** + * App Events - Type-safe builders + */ +export const AppEvents = { + queryParamsActivated: (name?: string, value?: string | number): AppEvent => ({ + category: 'App', + action: 'queryParams-activated', + name, + value, + isClick: false // Query param activation is a system event + }), + + loaded: (name?: string, value?: string | number): AppEvent => ({ + category: 'App', + action: 'loaded', + name, + value, + isClick: false // App loading is a system event + }), + + error: (name?: string, value?: string | number): AppEvent => ({ + category: 'App', + action: 'error', + name, + value, + isClick: false // App errors are system events + }), + + PreloadError: (name?: string, value?: string | number): AppEvent => ({ + category: 'App', + action: 'PreloadError', + name, + value, + isClick: false // Preload errors are system events + }), + + queryParamsCalls: (name?: string, value?: string | number): AppEvent => ({ + category: 'App', + action: 'queryParamsCalls', + name, + value, + isClick: false // Query parameter calls are system events + }) +} as const; + +/** + * Plugin Panel Events - Type-safe builders + */ +export const PluginPanelEvents = { + toggle: (name?: string, value?: string | number): PluginPanelEvent => ({ + category: 'pluginPanel', + action: 'toggle', + name, + value, + isClick: true // User toggles plugin panel + }), + + open: (name?: string, value?: string | number): PluginPanelEvent => ({ + category: 'pluginPanel', + action: 'open', + name, + value, + isClick: true // User opens plugin panel + }), + + close: (name?: string, value?: string | number): PluginPanelEvent => ({ + category: 'pluginPanel', + action: 'close', + name, + value, + isClick: true // User closes plugin panel + }), + + pinToRight: (name?: string, value?: string | number): PluginPanelEvent => ({ + category: 'pluginPanel', + action: 'pinToRight', + name, + value, + isClick: true // User pins panel to right + }), + + pinToLeft: (name?: string, value?: string | number): PluginPanelEvent => ({ + category: 'pluginPanel', + action: 'pinToLeft', + name, + value, + isClick: true // User pins panel to left + }) +} as const; + +/** + * Matomo Manager Events - Type-safe builders + */ +export const MatomoManagerEvents = { + initialize: (name?: string, value?: string | number): MatomoManagerEvent => ({ + category: 'MatomoManager', + action: 'initialize', + name, + value, + isClick: false // Initialization is a system event + }), + + switchMode: (name?: string, value?: string | number): MatomoManagerEvent => ({ + category: 'MatomoManager', + action: 'switchMode', + name, + value, + isClick: true // User switches tracking mode + }), + + trackEvent: (name?: string, value?: string | number): MatomoManagerEvent => ({ + category: 'MatomoManager', + action: 'trackEvent', + name, + value, + isClick: false // Event tracking is a system event + }), + + showConsentDialog: (name?: string, value?: string | number): MatomoManagerEvent => ({ + category: 'MatomoManager', + action: 'showConsentDialog', + name, + value, + isClick: false // Showing consent dialog is a system event + }) +} as const; + +/** + * Migrate Events - Type-safe builders + */ +export const MigrateEvents = { + start: (name?: string, value?: string | number): MigrateEvent => ({ + category: 'migrate', + action: 'start', + name, + value, + isClick: true // User starts migration process + }), + + complete: (name?: string, value?: string | number): MigrateEvent => ({ + category: 'migrate', + action: 'complete', + name, + value, + isClick: false // Migration completion is system event + }), + + error: (name?: string, value?: string | number): MigrateEvent => ({ + category: 'migrate', + action: 'error', + name, + value, + isClick: false // Migration errors are system events + }), + + result: (name?: string, value?: string | number): MigrateEvent => ({ + category: 'migrate', + action: 'result', + name, + value, + isClick: false // Migration result is system event + }) +} as const; \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/events/tools-events.ts b/libs/remix-api/src/lib/plugins/matomo/events/tools-events.ts new file mode 100644 index 00000000000..6ccd11a24ca --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/events/tools-events.ts @@ -0,0 +1,863 @@ +/** + * Tools Events - Developer tools and utilities tracking events + * + * This file contains events for debugger, editor, testing, and other developer tools. + */ + +import { MatomoEventBase } from '../core/base-types'; + +export interface DebuggerEvent extends MatomoEventBase { + category: 'debugger'; + action: + | 'start' + | 'step' + | 'breakpoint' + | 'startDebugging'; +} + +export interface EditorEvent extends MatomoEventBase { + category: 'editor'; + action: + | 'open' + | 'save' + | 'format' + | 'autocomplete' + | 'publishFromEditor' + | 'runScript' + | 'runScriptWithEnv' + | 'clickRunFromEditor' + | 'onDidPaste'; +} + +export interface SolidityUnitTestingEvent extends MatomoEventBase { + category: 'solidityUnitTesting'; + action: + | 'runTest' + | 'generateTest' + | 'testPassed' + | 'hardhat' + | 'runTests'; +} + +export interface SolidityStaticAnalyzerEvent extends MatomoEventBase { + category: 'solidityStaticAnalyzer'; + action: + | 'analyze' + | 'warningFound'; +} + +export interface DesktopDownloadEvent extends MatomoEventBase { + category: 'desktopDownload'; + action: + | 'download' + | 'click'; +} + +export interface GridViewEvent extends MatomoEventBase { + category: 'gridView'; + action: + | 'toggle' + | 'resize' + | 'rearrange' + | 'filterWithTitle'; +} + +export interface XTERMEvent extends MatomoEventBase { + category: 'xterm'; + action: + | 'terminal' + | 'command'; +} + +export interface SolidityScriptEvent extends MatomoEventBase { + category: 'solidityScript'; + action: + | 'execute' + | 'deploy' + | 'run' + | 'compile'; +} + +export interface RemixGuideEvent extends MatomoEventBase { + category: 'remixGuide'; + action: + | 'start' + | 'step' + | 'complete' + | 'skip' + | 'navigate' + | 'playGuide'; +} + +export interface TemplateSelectionEvent extends MatomoEventBase { + category: 'templateSelection'; + action: + | 'selectTemplate' + | 'createWorkspace' + | 'cancel' + | 'addToCurrentWorkspace'; +} + +export interface ScriptExecutorEvent extends MatomoEventBase { + category: 'scriptExecutor'; + action: + | 'execute' + | 'deploy' + | 'run' + | 'compile' + | 'compileAndRun'; +} + +/** + * Debugger Events - Type-safe builders + */ +export const DebuggerEvents = { + start: (name?: string, value?: string | number): DebuggerEvent => ({ + category: 'debugger', + action: 'start', + name, + value, + isClick: true // User starts debugging + }), + + step: (name?: string, value?: string | number): DebuggerEvent => ({ + category: 'debugger', + action: 'step', + name, + value, + isClick: true // User steps through code + }), + + breakpoint: (name?: string, value?: string | number): DebuggerEvent => ({ + category: 'debugger', + action: 'breakpoint', + name, + value, + isClick: true // User sets/removes breakpoint + }), + + startDebugging: (name?: string, value?: string | number): DebuggerEvent => ({ + category: 'debugger', + action: 'startDebugging', + name, + value, + isClick: true // User starts debugging session + }) +} as const; + +/** + * Editor Events - Type-safe builders + */ +export const EditorEvents = { + open: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'open', + name, + value, + isClick: true // User opens file + }), + + save: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'save', + name, + value, + isClick: true // User saves file + }), + + format: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'format', + name, + value, + isClick: true // User formats code + }), + + autocomplete: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'autocomplete', + name, + value, + isClick: false // Autocomplete is often automatic + }), + + publishFromEditor: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'publishFromEditor', + name, + value, + isClick: true // User publishes from editor + }), + + runScript: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'runScript', + name, + value, + isClick: true // User runs script from editor + }), + + runScriptWithEnv: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'runScriptWithEnv', + name, + value, + isClick: true // User runs script with specific environment + }), + + clickRunFromEditor: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'clickRunFromEditor', + name, + value, + isClick: true // User clicks run button in editor + }), + + onDidPaste: (name?: string, value?: string | number): EditorEvent => ({ + category: 'editor', + action: 'onDidPaste', + name, + value, + isClick: false // Paste event is system-triggered + }) +} as const; + +/** + * Solidity Unit Testing Events - Type-safe builders + */ +export const SolidityUnitTestingEvents = { + runTest: (name?: string, value?: string | number): SolidityUnitTestingEvent => ({ + category: 'solidityUnitTesting', + action: 'runTest', + name, + value, + isClick: true // User runs test + }), + + generateTest: (name?: string, value?: string | number): SolidityUnitTestingEvent => ({ + category: 'solidityUnitTesting', + action: 'generateTest', + name, + value, + isClick: true // User generates test + }), + + testPassed: (name?: string, value?: string | number): SolidityUnitTestingEvent => ({ + category: 'solidityUnitTesting', + action: 'testPassed', + name, + value, + isClick: false // Test passing is a system event + }), + + hardhat: (name?: string, value?: string | number): SolidityUnitTestingEvent => ({ + category: 'solidityUnitTesting', + action: 'hardhat', + name, + value, + isClick: true // User uses Hardhat features + }), + + runTests: (name?: string, value?: string | number): SolidityUnitTestingEvent => ({ + category: 'solidityUnitTesting', + action: 'runTests', + name, + value, + isClick: true // User runs multiple tests + }) +} as const; + +/** + * Static Analyzer Events - Type-safe builders + */ +export const SolidityStaticAnalyzerEvents = { + analyze: (name?: string, value?: string | number): SolidityStaticAnalyzerEvent => ({ + category: 'solidityStaticAnalyzer', + action: 'analyze', + name, + value, + isClick: true // User starts analysis + }), + + warningFound: (name?: string, value?: string | number): SolidityStaticAnalyzerEvent => ({ + category: 'solidityStaticAnalyzer', + action: 'warningFound', + name, + value, + isClick: false // Warning detection is system event + }) +} as const; + +/** + * Desktop Download Events - Type-safe builders + */ +export const DesktopDownloadEvents = { + download: (name?: string, value?: string | number): DesktopDownloadEvent => ({ + category: 'desktopDownload', + action: 'download', + name, + value, + isClick: true // User downloads desktop app + }), + + click: (name?: string, value?: string | number): DesktopDownloadEvent => ({ + category: 'desktopDownload', + action: 'click', + name, + value, + isClick: true // User clicks on desktop download + }) +} as const; + +/** + * Terminal Events - Type-safe builders + */ +export const XTERMEvents = { + terminal: (name?: string, value?: string | number): XTERMEvent => ({ + category: 'xterm', + action: 'terminal', + name, + value, + isClick: true // User interacts with terminal + }), + + command: (name?: string, value?: string | number): XTERMEvent => ({ + category: 'xterm', + action: 'command', + name, + value, + isClick: false // Command execution is system event + }) +} as const; + +/** + * Solidity Script Events - Type-safe builders + */ +export const SolidityScriptEvents = { + execute: (name?: string, value?: string | number): SolidityScriptEvent => ({ + category: 'solidityScript', + action: 'execute', + name, + value, + isClick: true // User executes Solidity script + }), + + deploy: (name?: string, value?: string | number): SolidityScriptEvent => ({ + category: 'solidityScript', + action: 'deploy', + name, + value, + isClick: true // User deploys through script + }), + + run: (name?: string, value?: string | number): SolidityScriptEvent => ({ + category: 'solidityScript', + action: 'run', + name, + value, + isClick: true // User runs script + }), + + compile: (name?: string, value?: string | number): SolidityScriptEvent => ({ + category: 'solidityScript', + action: 'compile', + name, + value, + isClick: true // User compiles through script + }) +} as const; + +/** + * Remix Guide Events - Type-safe builders + */ +export const RemixGuideEvents = { + start: (name?: string, value?: string | number): RemixGuideEvent => ({ + category: 'remixGuide', + action: 'start', + name, + value, + isClick: true // User starts guide + }), + + step: (name?: string, value?: string | number): RemixGuideEvent => ({ + category: 'remixGuide', + action: 'step', + name, + value, + isClick: true // User navigates to guide step + }), + + complete: (name?: string, value?: string | number): RemixGuideEvent => ({ + category: 'remixGuide', + action: 'complete', + name, + value, + isClick: true // User completes guide + }), + + skip: (name?: string, value?: string | number): RemixGuideEvent => ({ + category: 'remixGuide', + action: 'skip', + name, + value, + isClick: true // User skips guide step + }), + + navigate: (name?: string, value?: string | number): RemixGuideEvent => ({ + category: 'remixGuide', + action: 'navigate', + name, + value, + isClick: true // User navigates within guide + }), + + playGuide: (name?: string, value?: string | number): RemixGuideEvent => ({ + category: 'remixGuide', + action: 'playGuide', + name, + value, + isClick: true // User plays/starts a specific guide + }) +} as const; + +/** + * Template Selection Events - Type-safe builders + */ +export const TemplateSelectionEvents = { + selectTemplate: (name?: string, value?: string | number): TemplateSelectionEvent => ({ + category: 'templateSelection', + action: 'selectTemplate', + name, + value, + isClick: true // User selects a template + }), + + createWorkspace: (name?: string, value?: string | number): TemplateSelectionEvent => ({ + category: 'templateSelection', + action: 'createWorkspace', + name, + value, + isClick: true // User creates workspace from template + }), + + cancel: (name?: string, value?: string | number): TemplateSelectionEvent => ({ + category: 'templateSelection', + action: 'cancel', + name, + value, + isClick: true // User cancels template selection + }), + + addToCurrentWorkspace: (name?: string, value?: string | number): TemplateSelectionEvent => ({ + category: 'templateSelection', + action: 'addToCurrentWorkspace', + name, + value, + isClick: true // User adds template to current workspace + }) +} as const; + +/** + * Script Executor Events - Type-safe builders + */ +export const ScriptExecutorEvents = { + execute: (name?: string, value?: string | number): ScriptExecutorEvent => ({ + category: 'scriptExecutor', + action: 'execute', + name, + value, + isClick: true // User executes script + }), + + deploy: (name?: string, value?: string | number): ScriptExecutorEvent => ({ + category: 'scriptExecutor', + action: 'deploy', + name, + value, + isClick: true // User deploys through script executor + }), + + run: (name?: string, value?: string | number): ScriptExecutorEvent => ({ + category: 'scriptExecutor', + action: 'run', + name, + value, + isClick: true // User runs script executor + }), + + compile: (name?: string, value?: string | number): ScriptExecutorEvent => ({ + category: 'scriptExecutor', + action: 'compile', + name, + value, + isClick: true // User compiles through script executor + }), + + compileAndRun: (name?: string, value?: string | number): ScriptExecutorEvent => ({ + category: 'scriptExecutor', + action: 'compileAndRun', + name, + value, + isClick: true // User compiles and runs script + }) +} as const; + +/** + * Grid View Events - Type-safe builders + */ +export const GridViewEvents = { + toggle: (name?: string, value?: string | number): GridViewEvent => ({ + category: 'gridView', + action: 'toggle', + name, + value, + isClick: true // User toggles grid view + }), + + resize: (name?: string, value?: string | number): GridViewEvent => ({ + category: 'gridView', + action: 'resize', + name, + value, + isClick: false // User resizes grid view + }), + + rearrange: (name?: string, value?: string | number): GridViewEvent => ({ + category: 'gridView', + action: 'rearrange', + name, + value, + isClick: true // User rearranges grid view items + }), + + filterWithTitle: (name?: string, value?: string | number): GridViewEvent => ({ + category: 'gridView', + action: 'filterWithTitle', + name, + value, + isClick: true // User filters grid view with title + }) +} as const; + +/** + * Solidity UML Generation Events - Type-safe builders + */ +export interface SolidityUMLGenEvent extends MatomoEventBase { + category: 'solidityUMLGen'; + action: + | 'umlpngdownload' + | 'umlpdfdownload' + | 'generate' + | 'export' + | 'umlgenerated' + | 'activated'; +} + +export const SolidityUMLGenEvents = { + umlpngdownload: (name?: string, value?: string | number): SolidityUMLGenEvent => ({ + category: 'solidityUMLGen', + action: 'umlpngdownload', + name, + value, + isClick: true // User downloads UML as PNG + }), + + umlpdfdownload: (name?: string, value?: string | number): SolidityUMLGenEvent => ({ + category: 'solidityUMLGen', + action: 'umlpdfdownload', + name, + value, + isClick: true // User downloads UML as PDF + }), + + generate: (name?: string, value?: string | number): SolidityUMLGenEvent => ({ + category: 'solidityUMLGen', + action: 'generate', + name, + value, + isClick: true // User generates UML diagram + }), + + export: (name?: string, value?: string | number): SolidityUMLGenEvent => ({ + category: 'solidityUMLGen', + action: 'export', + name, + value, + isClick: true // User exports UML diagram + }), + + umlgenerated: (name?: string, value?: string | number): SolidityUMLGenEvent => ({ + category: 'solidityUMLGen', + action: 'umlgenerated', + name, + value, + isClick: false // UML generation completion is system event + }), + + activated: (name?: string, value?: string | number): SolidityUMLGenEvent => ({ + category: 'solidityUMLGen', + action: 'activated', + name, + value, + isClick: true // User activates UML generation plugin + }) +} as const; + +// Alias for compatibility +export const SolUmlGenEvents = SolidityUMLGenEvents; + +/** + * Circuit Compiler Events - Type-safe builders + */ +export interface CircuitCompilerEvent extends MatomoEventBase { + category: 'circuitCompiler'; + action: + | 'compile' + | 'generateProof' + | 'error' + | 'generateR1cs' + | 'computeWitness'; +} + +export const CircuitCompilerEvents = { + compile: (name?: string, value?: string | number): CircuitCompilerEvent => ({ + category: 'circuitCompiler', + action: 'compile', + name, + value, + isClick: true // User compiles circuit + }), + + generateProof: (name?: string, value?: string | number): CircuitCompilerEvent => ({ + category: 'circuitCompiler', + action: 'generateProof', + name, + value, + isClick: true // User generates proof + }), + + error: (name?: string, value?: string | number): CircuitCompilerEvent => ({ + category: 'circuitCompiler', + action: 'error', + name, + value, + isClick: false // Compiler errors are system events + }), + + generateR1cs: (name?: string, value?: string | number): CircuitCompilerEvent => ({ + category: 'circuitCompiler', + action: 'generateR1cs', + name, + value, + isClick: true // User generates R1CS + }), + + computeWitness: (name?: string, value?: string | number): CircuitCompilerEvent => ({ + category: 'circuitCompiler', + action: 'computeWitness', + name, + value, + isClick: true // User computes witness + }) +} as const; + +/** + * Contract Verification Events - Type-safe builders + */ +export interface ContractVerificationEvent extends MatomoEventBase { + category: 'contractVerification'; + action: + | 'verify' + | 'lookup'; +} + +export const ContractVerificationEvents = { + verify: (name?: string, value?: string | number): ContractVerificationEvent => ({ + category: 'contractVerification', + action: 'verify', + name, + value, + isClick: true // User initiates contract verification + }), + + lookup: (name?: string, value?: string | number): ContractVerificationEvent => ({ + category: 'contractVerification', + action: 'lookup', + name, + value, + isClick: true // User looks up contract verification + }) +} as const; + +/** + * Learneth Events - Type-safe builders + */ +export interface LearnethEvent extends MatomoEventBase { + category: 'learneth'; + action: + | 'start' + | 'complete' + | 'lesson' + | 'tutorial' + | 'error' + | 'displayFile' + | 'displayFileError' + | 'testStep' + | 'testStepError' + | 'showAnswer' + | 'showAnswerError' + | 'testSolidityCompiler' + | 'testSolidityCompilerError'; +} + +export const LearnethEvents = { + start: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'start', + name, + value, + isClick: true // User starts learning session + }), + + complete: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'complete', + name, + value, + isClick: false // Lesson completion is system event + }), + + lesson: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'lesson', + name, + value, + isClick: true // User interacts with lesson + }), + + tutorial: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'tutorial', + name, + value, + isClick: true // User interacts with tutorial + }), + + error: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'error', + name, + value, + isClick: false // Learning errors are system events + }), + + displayFile: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'displayFile', + name, + value, + isClick: true // User displays file in learning context + }), + + displayFileError: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'displayFileError', + name, + value, + isClick: false // Error displaying file + }), + + testStep: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'testStep', + name, + value, + isClick: true // User executes test step + }), + + testStepError: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'testStepError', + name, + value, + isClick: false // Error in test step + }), + + showAnswer: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'showAnswer', + name, + value, + isClick: true // User shows answer + }), + + showAnswerError: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'showAnswerError', + name, + value, + isClick: false // Error showing answer + }), + + testSolidityCompiler: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'testSolidityCompiler', + name, + value, + isClick: true // User tests Solidity compiler + }), + + testSolidityCompilerError: (name?: string, value?: string | number): LearnethEvent => ({ + category: 'learneth', + action: 'testSolidityCompilerError', + name, + value, + isClick: false // Error testing Solidity compiler + }) +} as const; + +/** + * Script Runner Plugin Events - Type-safe builders + */ +export interface ScriptRunnerPluginEvent extends MatomoEventBase { + category: 'scriptRunnerPlugin'; + action: + | 'loadScriptRunnerConfig' + | 'error_reloadScriptRunnerConfig' + | 'executeScript' + | 'configChanged'; +} + +export const ScriptRunnerPluginEvents = { + loadScriptRunnerConfig: (name?: string, value?: string | number): ScriptRunnerPluginEvent => ({ + category: 'scriptRunnerPlugin', + action: 'loadScriptRunnerConfig', + name, + value, + isClick: true // User loads script runner config + }), + + error_reloadScriptRunnerConfig: (name?: string, value?: string | number): ScriptRunnerPluginEvent => ({ + category: 'scriptRunnerPlugin', + action: 'error_reloadScriptRunnerConfig', + name, + value, + isClick: false // Error reloading script runner config + }), + + executeScript: (name?: string, value?: string | number): ScriptRunnerPluginEvent => ({ + category: 'scriptRunnerPlugin', + action: 'executeScript', + name, + value, + isClick: true // User executes script + }), + + configChanged: (name?: string, value?: string | number): ScriptRunnerPluginEvent => ({ + category: 'scriptRunnerPlugin', + action: 'configChanged', + name, + value, + isClick: true // User changes script runner config + }) +} as const; \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/events/ui-events.ts b/libs/remix-api/src/lib/plugins/matomo/events/ui-events.ts new file mode 100644 index 00000000000..e90c8d46b1c --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/events/ui-events.ts @@ -0,0 +1,305 @@ +/** + * UI Events - User interface and navigation tracking events + * + * This file contains UI-related events like home tab, topbar, and navigation. + */ + +import { MatomoEventBase } from '../core/base-types'; + +export interface HomeTabEvent extends MatomoEventBase { + category: 'hometab'; + action: + | 'header' + | 'filesSection' + | 'scamAlert' + | 'switchTo' + | 'titleCard' + | 'recentWorkspacesCard' + | 'featuredPluginsToggle' + | 'featuredPluginsActionClick' + | 'updatesActionClick' + | 'homeGetStarted' + | 'startLearnEthTutorial' + | 'featuredSection'; +} + +export interface TopbarEvent extends MatomoEventBase { + category: 'topbar'; + action: + | 'GIT' + | 'header'; +} + +export interface LayoutEvent extends MatomoEventBase { + category: 'layout'; + action: + | 'pinToRight' + | 'pinToLeft'; +} + +export interface SettingsEvent extends MatomoEventBase { + category: 'settings'; + action: + | 'change'; +} + +export interface ThemeEvent extends MatomoEventBase { + category: 'theme'; + action: + | 'switchThemeTo'; +} + +export interface LocaleEvent extends MatomoEventBase { + category: 'locale'; + action: + | 'switchTo'; +} + +export interface LandingPageEvent extends MatomoEventBase { + category: 'landingPage'; + action: + | 'welcome' + | 'getStarted' + | 'tutorial' + | 'documentation' + | 'templates' + | 'MatomoAIModal'; +} + +/** + * Home Tab Events - Type-safe builders + */ +export const HomeTabEvents = { + header: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'header', + name, + value, + isClick: true // User clicks on header elements + }), + + filesSection: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'filesSection', + name, + value, + isClick: true // User clicks on items in files section + }), + + homeGetStarted: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'homeGetStarted', + name, + value, + isClick: true // User clicks get started templates + }), + + featuredSection: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'featuredSection', + name, + value, + isClick: true // User clicks on featured section items + }), + + scamAlert: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'scamAlert', + name, + value, + isClick: true // User interacts with scam alert functionality + }), + + titleCard: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'titleCard', + name, + value, + isClick: true // User clicks on title cards + }), + + startLearnEthTutorial: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'startLearnEthTutorial', + name, + value, + isClick: true // User starts a LearnEth tutorial + }), + + updatesActionClick: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'updatesActionClick', + name, + value, + isClick: true // User clicks on updates actions + }), + + featuredPluginsToggle: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'featuredPluginsToggle', + name, + value, + isClick: true // User toggles featured plugins + }), + + featuredPluginsActionClick: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'featuredPluginsActionClick', + name, + value, + isClick: true // User clicks action in featured plugins + }), + + recentWorkspacesCard: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'recentWorkspacesCard', + name, + value, + isClick: true // User interacts with recent workspaces card + }), + + switchTo: (name?: string, value?: string | number): HomeTabEvent => ({ + category: 'hometab', + action: 'switchTo', + name, + value, + isClick: true // User switches to different view/mode + }) +} as const; + +/** + * Topbar Events - Type-safe builders + */ +export const TopbarEvents = { + GIT: (name?: string, value?: string | number): TopbarEvent => ({ + category: 'topbar', + action: 'GIT', + name, + value, + isClick: true // User clicks Git button in topbar + }), + + header: (name?: string, value?: string | number): TopbarEvent => ({ + category: 'topbar', + action: 'header', + name, + value, + isClick: true // User clicks header elements + }) +} as const; + +/** + * Layout Events - Type-safe builders + */ +export const LayoutEvents = { + pinToRight: (name?: string, value?: string | number): LayoutEvent => ({ + category: 'layout', + action: 'pinToRight', + name, + value, + isClick: true // User clicks to pin panel to right + }), + + pinToLeft: (name?: string, value?: string | number): LayoutEvent => ({ + category: 'layout', + action: 'pinToLeft', + name, + value, + isClick: true // User clicks to pin panel to left + }) +} as const; + +/** + * Settings Events - Type-safe builders + */ +export const SettingsEvents = { + change: (name?: string, value?: string | number): SettingsEvent => ({ + category: 'settings', + action: 'change', + name, + value, + isClick: true // User changes settings + }) +} as const; + +/** + * Theme Events - Type-safe builders + */ +export const ThemeModuleEvents = { + switchThemeTo: (themeName?: string, value?: string | number): ThemeEvent => ({ + category: 'theme', + action: 'switchThemeTo', + name: themeName, + value, + isClick: true // User switches theme + }) +} as const; + +/** + * Locale Events - Type-safe builders + */ +export const LocaleModuleEvents = { + switchTo: (localeCode?: string, value?: string | number): LocaleEvent => ({ + category: 'locale', + action: 'switchTo', + name: localeCode, + value, + isClick: true // User switches locale + }) +} as const; + +/** + * Landing Page Events - Type-safe builders + */ +export const LandingPageEvents = { + welcome: (name?: string, value?: string | number): LandingPageEvent => ({ + category: 'landingPage', + action: 'welcome', + name, + value, + isClick: true // User interacts with welcome section + }), + + getStarted: (name?: string, value?: string | number): LandingPageEvent => ({ + category: 'landingPage', + action: 'getStarted', + name, + value, + isClick: true // User clicks get started buttons + }), + + tutorial: (name?: string, value?: string | number): LandingPageEvent => ({ + category: 'landingPage', + action: 'tutorial', + name, + value, + isClick: true // User starts tutorials + }), + + documentation: (name?: string, value?: string | number): LandingPageEvent => ({ + category: 'landingPage', + action: 'documentation', + name, + value, + isClick: true // User clicks documentation links + }), + + templates: (name?: string, value?: string | number): LandingPageEvent => ({ + category: 'landingPage', + action: 'templates', + name, + value, + isClick: true // User selects templates + }), + + MatomoAIModal: (name?: string, value?: string | number): LandingPageEvent => ({ + category: 'landingPage', + action: 'MatomoAIModal', + name, + value, + isClick: true // User interacts with Matomo AI modal settings + }) +} as const; + +// Naming compatibility aliases +export const TopBarEvents = TopbarEvents; // Alias for backward compatibility \ No newline at end of file diff --git a/libs/remix-api/src/lib/plugins/matomo/index.ts b/libs/remix-api/src/lib/plugins/matomo/index.ts new file mode 100644 index 00000000000..c0b9cbd855b --- /dev/null +++ b/libs/remix-api/src/lib/plugins/matomo/index.ts @@ -0,0 +1,150 @@ +/** + * Matomo Events - Modular Event System + * + * This is the main index for the split Matomo event system. + * It re-exports all events to maintain backward compatibility while + * organizing the code into manageable modules. + * + * Usage: + * import { AIEvents, GitEvents, trackMatomoEvent } from '@remix-api' + * + * trackMatomoEvent(AIEvents.remixAI('code_generation')) + * trackMatomoEvent(GitEvents.COMMIT('success')) + */ + +// Core types and categories +export * from './core/base-types'; +export * from './core/categories'; + +// Event modules - organized by domain +export * from './events/ai-events'; +export * from './events/compiler-events'; +export * from './events/git-events'; +export * from './events/ui-events'; +export * from './events/file-events'; +export * from './events/blockchain-events'; +export * from './events/plugin-events'; +export * from './events/tools-events'; + +// Import types for union +import type { AIEvent, RemixAIEvent, RemixAIAssistantEvent } from './events/ai-events'; +import type { CompilerEvent, SolidityCompilerEvent, CompilerContainerEvent } from './events/compiler-events'; +import type { GitEvent } from './events/git-events'; +import type { HomeTabEvent, TopbarEvent, LayoutEvent, SettingsEvent, ThemeEvent, LocaleEvent, LandingPageEvent } from './events/ui-events'; +import type { FileExplorerEvent, WorkspaceEvent, StorageEvent, BackupEvent } from './events/file-events'; +import type { BlockchainEvent, UdappEvent, RunEvent } from './events/blockchain-events'; +import type { PluginEvent, ManagerEvent, PluginManagerEvent, AppEvent, MatomoManagerEvent, PluginPanelEvent, MigrateEvent } from './events/plugin-events'; +import type { DebuggerEvent, EditorEvent, SolidityUnitTestingEvent, SolidityStaticAnalyzerEvent, DesktopDownloadEvent, XTERMEvent, SolidityScriptEvent, RemixGuideEvent, TemplateSelectionEvent, ScriptExecutorEvent, GridViewEvent, SolidityUMLGenEvent, ScriptRunnerPluginEvent, CircuitCompilerEvent, ContractVerificationEvent, LearnethEvent } from './events/tools-events'; + +// Union type of all Matomo events - includes base properties for compatibility +export type MatomoEvent = ( + // AI & Assistant events + | AIEvent + | RemixAIEvent + | RemixAIAssistantEvent + + // Compilation events + | CompilerEvent + | SolidityCompilerEvent + | CompilerContainerEvent + + // Version Control events + | GitEvent + + // User Interface events + | HomeTabEvent + | TopbarEvent + | LayoutEvent + | SettingsEvent + | ThemeEvent + | LocaleEvent + | LandingPageEvent + + // File Management events + | FileExplorerEvent + | WorkspaceEvent + | StorageEvent + | BackupEvent + + // Blockchain & Contract events + | BlockchainEvent + | UdappEvent + | RunEvent + + // Plugin Management events + | PluginEvent + | ManagerEvent + | PluginManagerEvent + | AppEvent + | MatomoManagerEvent + | PluginPanelEvent + | MigrateEvent + + // Development Tools events + | DebuggerEvent + | EditorEvent + | SolidityUnitTestingEvent + | SolidityStaticAnalyzerEvent + | DesktopDownloadEvent + | XTERMEvent + | SolidityScriptEvent + | RemixGuideEvent + | TemplateSelectionEvent + | ScriptExecutorEvent + | GridViewEvent + | SolidityUMLGenEvent + | ScriptRunnerPluginEvent + | CircuitCompilerEvent + | ContractVerificationEvent + | LearnethEvent + +) & { + // Ensure all events have these base properties for backward compatibility + name?: string; + value?: string | number; + isClick?: boolean; +} + +// Note: This is a demonstration of the split structure +// In the full implementation, you would need to extract ALL event types from the original +// 2351-line file into appropriate category modules: +// +// - blockchain-events.ts (BlockchainEvent, UdappEvent) +// - file-events.ts (FileExplorerEvent, WorkspaceEvent) +// - plugin-events.ts (PluginEvent, ManagerEvent, etc.) +// - app-events.ts (AppEvent, StorageEvent, etc.) +// - debug-events.ts (DebuggerEvent, MatomoManagerEvent) +// - template-events.ts (TemplateSelectionEvent, etc.) +// - circuit-events.ts (CircuitCompilerEvent) +// - learneth-events.ts (LearnethEvent) +// - desktop-events.ts (DesktopDownloadEvent) +// - editor-events.ts (EditorEvent) +// +// Each would follow the same pattern: +// 1. Define the TypeScript interface +// 2. Export type-safe builder functions +// 3. Keep files focused and manageable (~200-400 lines each) + +// For backward compatibility, the original matomo-events.ts file would +// be replaced with just: +// export * from './matomo'; + +// Example of how other files would be structured: + +/* +// blockchain-events.ts +export interface BlockchainEvent extends MatomoEventBase { + category: 'blockchain'; + action: 'providerChanged' | 'networkChanged' | 'accountChanged'; +} + +export const BlockchainEvents = { + providerChanged: (name?: string, value?: string | number): BlockchainEvent => ({ + category: 'blockchain', + action: 'providerChanged', + name, + value, + isClick: true + }) +} as const; +*/ \ No newline at end of file diff --git a/libs/remix-ui/app/src/lib/remix-app/components/modals/managePreferences.tsx b/libs/remix-ui/app/src/lib/remix-app/components/modals/managePreferences.tsx index 58fdf0251a0..a3a2dfed162 100644 --- a/libs/remix-ui/app/src/lib/remix-app/components/modals/managePreferences.tsx +++ b/libs/remix-ui/app/src/lib/remix-app/components/modals/managePreferences.tsx @@ -3,12 +3,8 @@ import { FormattedMessage } from 'react-intl' import { useDialogDispatchers } from '../../context/provider' import { ToggleSwitch } from '@remix-ui/toggle' import { AppContext } from '../../context/context' -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { LandingPageEvents } from '@remix-api' const ManagePreferencesSwitcher = (prop: { setParentState: (state: any) => void @@ -94,6 +90,7 @@ const ManagePreferencesSwitcher = (prop: { const ManagePreferencesDialog = (props) => { const { modal } = useDialogDispatchers() const { settings } = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const [visible, setVisible] = useState(true) const switcherState = useRef>(null) @@ -114,12 +111,11 @@ const ManagePreferencesDialog = (props) => { }, [visible]) const savePreferences = async () => { - _paq.push(['setConsentGiven']) // default consent to process their anonymous data - settings.updateMatomoAnalyticsChoice(true) // Always true for matomo Anonymous analytics + // Consent is managed by cookie consent system in settings settings.updateMatomoPerfAnalyticsChoice(switcherState.current.matPerfSwitch) // Enable/Disable Matomo Performance analytics settings.updateCopilotChoice(switcherState.current.remixAISwitch) // Enable/Disable RemixAI copilot - _paq.push(['trackEvent', 'landingPage', 'MatomoAIModal', `MatomoPerfStatus: ${switcherState.current.matPerfSwitch}`]) - _paq.push(['trackEvent', 'landingPage', 'MatomoAIModal', `AICopilotStatus: ${switcherState.current.remixAISwitch}`]) + trackMatomoEvent?.(LandingPageEvents.MatomoAIModal(`MatomoPerfStatus: ${switcherState.current.matPerfSwitch}`)) + trackMatomoEvent?.(LandingPageEvents.MatomoAIModal(`AICopilotStatus: ${switcherState.current.remixAISwitch}`)) setVisible(false) } diff --git a/libs/remix-ui/app/src/lib/remix-app/components/modals/matomo.tsx b/libs/remix-ui/app/src/lib/remix-app/components/modals/matomo.tsx index 7447ad75d08..f4fb070f97e 100644 --- a/libs/remix-ui/app/src/lib/remix-app/components/modals/matomo.tsx +++ b/libs/remix-ui/app/src/lib/remix-app/components/modals/matomo.tsx @@ -2,12 +2,8 @@ import React, { useContext, useEffect, useState } from 'react' import { FormattedMessage } from 'react-intl' import { AppContext } from '../../context/context' import { useDialogDispatchers } from '../../context/provider' -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { LandingPageEvents } from '@remix-api' interface MatomoDialogProps { managePreferencesFn: () => void @@ -16,6 +12,7 @@ interface MatomoDialogProps { const MatomoDialog = (props: MatomoDialogProps) => { const { settings, showMatomo } = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const { modal } = useDialogDispatchers() const [visible, setVisible] = useState(props.hide) @@ -65,16 +62,15 @@ const MatomoDialog = (props: MatomoDialogProps) => { }, [visible]) const handleAcceptAllClick = async () => { - _paq.push(['setConsentGiven']) // default consent to process their anonymous data - settings.updateMatomoAnalyticsChoice(true) // Enable Matomo Anonymous analytics + // Consent is managed by cookie consent system in settings settings.updateMatomoPerfAnalyticsChoice(true) // Enable Matomo Performance analytics settings.updateCopilotChoice(true) // Enable RemixAI copilot - _paq.push(['trackEvent', 'landingPage', 'MatomoAIModal', 'AcceptClicked']) + trackMatomoEvent?.(LandingPageEvents.MatomoAIModal('AcceptClicked')) setVisible(false) } const handleManagePreferencesClick = async () => { - _paq.push(['trackEvent', 'landingPage', 'MatomoAIModal', 'ManagePreferencesClicked']) + trackMatomoEvent?.(LandingPageEvents.MatomoAIModal('ManagePreferencesClicked')) setVisible(false) props.managePreferencesFn() } diff --git a/libs/remix-ui/app/src/lib/remix-app/remix-app.tsx b/libs/remix-ui/app/src/lib/remix-app/remix-app.tsx index 58c87575a24..47416e46e6e 100644 --- a/libs/remix-ui/app/src/lib/remix-app/remix-app.tsx +++ b/libs/remix-ui/app/src/lib/remix-app/remix-app.tsx @@ -15,13 +15,6 @@ import { appInitialState } from './state/app' import isElectron from 'is-electron' import { desktopConnectionType } from '@remix-api' -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) - interface IRemixAppUi { app: any } diff --git a/libs/remix-ui/debugger-ui/src/lib/debugger-ui.tsx b/libs/remix-ui/debugger-ui/src/lib/debugger-ui.tsx index da9ad009a41..6e8b876d3d4 100644 --- a/libs/remix-ui/debugger-ui/src/lib/debugger-ui.tsx +++ b/libs/remix-ui/debugger-ui/src/lib/debugger-ui.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect, useRef} from 'react' // eslint-disable-line +import React, {useState, useEffect, useRef, useContext} from 'react' // eslint-disable-line import { FormattedMessage, useIntl } from 'react-intl' import TxBrowser from './tx-browser/tx-browser' // eslint-disable-line import StepManager from './step-manager/step-manager' // eslint-disable-line @@ -8,12 +8,14 @@ import {TransactionDebugger as Debugger} from '@remix-project/remix-debug' // es import {DebuggerUIProps} from './idebugger-api' // eslint-disable-line import {Toaster} from '@remix-ui/toaster' // eslint-disable-line import { CustomTooltip, isValidHash } from '@remix-ui/helper' +import { DebuggerEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' /* eslint-disable-next-line */ import './debugger-ui.css' -const _paq = ((window as any)._paq = (window as any)._paq || []) export const DebuggerUI = (props: DebuggerUIProps) => { const intl = useIntl() + const { trackMatomoEvent } = useContext(TrackingContext) const debuggerModule = props.debuggerAPI const [state, setState] = useState({ isActive: false, @@ -259,7 +261,7 @@ export const DebuggerUI = (props: DebuggerUIProps) => { const web3 = optWeb3 || (state.opt.debugWithLocalNode ? await debuggerModule.web3() : await debuggerModule.getDebugWeb3()) try { const networkId = await web3.eth.net.getId() - _paq.push(['trackEvent', 'debugger', 'startDebugging', networkId]) + trackMatomoEvent?.(DebuggerEvents.startDebugging(networkId)) if (networkId === 42) { setState((prevState) => { return { diff --git a/libs/remix-ui/desktop-download/lib/desktop-download.tsx b/libs/remix-ui/desktop-download/lib/desktop-download.tsx index e03ce39dfd9..373475ec3bf 100644 --- a/libs/remix-ui/desktop-download/lib/desktop-download.tsx +++ b/libs/remix-ui/desktop-download/lib/desktop-download.tsx @@ -1,9 +1,9 @@ -import React, { useState, useEffect } from 'react' +import React, { useEffect, useState, useContext } from 'react' +import { DesktopDownloadEvents } from '@remix-api' import { CustomTooltip } from '@remix-ui/helper' import { FormattedMessage } from 'react-intl' import './desktop-download.css' - -const _paq = (window._paq = window._paq || []) // eslint-disable-line +import { TrackingContext } from '@remix-ide/tracking' interface DesktopDownloadProps { className?: string @@ -49,6 +49,7 @@ export const DesktopDownload: React.FC = ({ const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [detectedDownload, setDetectedDownload] = useState(null) + const { trackMatomoEvent } = useContext(TrackingContext) // Detect user's operating system const detectOS = (): 'windows' | 'macos' | 'linux' => { @@ -192,13 +193,10 @@ export const DesktopDownload: React.FC = ({ // Track download click events const trackDownloadClick = (platform?: string, filename?: string, variant?: string) => { - const trackingData = [ - 'trackEvent', - 'desktopDownload', + trackMatomoEvent?.(DesktopDownloadEvents.click( `${trackingContext}-${variant || 'button'}`, platform ? `${platform}-${filename}` : 'releases-page' - ] - _paq.push(trackingData) + )) } // Load release data on component mount diff --git a/libs/remix-ui/editor/src/lib/providers/inlineCompletionProvider.ts b/libs/remix-ui/editor/src/lib/providers/inlineCompletionProvider.ts index 3431ce13ebf..df538d62eb3 100644 --- a/libs/remix-ui/editor/src/lib/providers/inlineCompletionProvider.ts +++ b/libs/remix-ui/editor/src/lib/providers/inlineCompletionProvider.ts @@ -1,6 +1,7 @@ /* eslint-disable no-control-regex */ import { EditorUIProps, monacoTypes } from '@remix-ui/editor'; import { CompletionParams } from '@remix/remix-ai-core'; +import { AIEvents, MatomoEvent } from '@remix-api'; import * as monaco from 'monaco-editor'; import { AdaptiveRateLimiter, @@ -8,22 +9,22 @@ import { CompletionCache, } from '../inlineCompetionsLibs'; -const _paq = (window._paq = window._paq || []) - export class RemixInLineCompletionProvider implements monacoTypes.languages.InlineCompletionsProvider { props: EditorUIProps monaco: any completionEnabled: boolean task: string = 'code_completion' currentCompletion: any + trackMatomoEvent?: (event: MatomoEvent) => void private rateLimiter: AdaptiveRateLimiter; private contextDetector: SmartContextDetector; private cache: CompletionCache; - constructor(props: any, monaco: any) { + constructor(props: any, monaco: any, trackMatomoEvent?: (event: MatomoEvent) => void) { this.props = props this.monaco = monaco + this.trackMatomoEvent = trackMatomoEvent this.completionEnabled = true this.currentCompletion = { text: '', @@ -201,7 +202,7 @@ export class RemixInLineCompletionProvider implements monacoTypes.languages.Inli }) const data = await this.props.plugin.call('remixAI', 'code_insertion', word, word_after) - _paq.push(['trackEvent', 'ai', 'remixAI', 'code_generation']) + this.trackMatomoEvent?.(AIEvents.codeGeneration()) this.task = 'code_generation' const parsedData = data.trimStart() @@ -227,7 +228,7 @@ export class RemixInLineCompletionProvider implements monacoTypes.languages.Inli try { CompletionParams.stop = ['\n\n', '```'] const output = await this.props.plugin.call('remixAI', 'code_insertion', word, word_after, CompletionParams) - _paq.push(['trackEvent', 'ai', 'remixAI', 'code_insertion']) + this.trackMatomoEvent?.(AIEvents.codeInsertion()) const generatedText = output this.task = 'code_insertion' @@ -258,7 +259,7 @@ export class RemixInLineCompletionProvider implements monacoTypes.languages.Inli CompletionParams.stop = ['\n', '```'] this.task = 'code_completion' const output = await this.props.plugin.call('remixAI', 'code_completion', word, word_after, CompletionParams) - _paq.push(['trackEvent', 'ai', 'remixAI', 'code_completion']) + this.trackMatomoEvent?.(AIEvents.codeCompletion()) const generatedText = output let clean = generatedText @@ -306,7 +307,7 @@ export class RemixInLineCompletionProvider implements monacoTypes.languages.Inli this.currentCompletion.task = this.task this.rateLimiter.trackCompletionShown() - _paq.push(['trackEvent', 'ai', 'remixAI', this.task + '_did_show']) + this.trackMatomoEvent?.(AIEvents.remixAI(this.task + '_did_show')) } handlePartialAccept?( @@ -318,7 +319,7 @@ export class RemixInLineCompletionProvider implements monacoTypes.languages.Inli this.currentCompletion.task = this.task this.rateLimiter.trackCompletionAccepted() - _paq.push(['trackEvent', 'ai', 'remixAI', this.task + '_partial_accept']) + this.trackMatomoEvent?.(AIEvents.remixAI(this.task + '_partial_accept')) } freeInlineCompletions( diff --git a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx index aa913e823d4..173ca7a60fa 100644 --- a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx +++ b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx @@ -1,9 +1,11 @@ -import React, { useState, useRef, useEffect, useReducer } from 'react' // eslint-disable-line +import React, { useState, useRef, useEffect, useReducer, useContext } from 'react' // eslint-disable-line import { FormattedMessage, useIntl } from 'react-intl' import { diffLines } from 'diff' import { isArray } from 'lodash' import Editor, { DiffEditor, loader, Monaco } from '@monaco-editor/react' -import { AppModal } from '@remix-ui/app' +import { AppContext, AppModal } from '@remix-ui/app' +import { AIEvents, EditorEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' import { ConsoleLogs, EventManager, QueryParams } from '@remix-project/remix-lib' import { reducerActions, reducerListener, initialState } from './actions/editor' import { solidityTokensProvider, solidityLanguageConfig } from './syntaxes/solidity' @@ -28,7 +30,6 @@ import { noirLanguageConfig, noirTokensProvider } from './syntaxes/noir' import { IPosition, IRange } from 'monaco-editor' import { GenerationParams } from '@remix/remix-ai-core'; import { RemixInLineCompletionProvider } from './providers/inlineCompletionProvider' -const _paq = (window._paq = window._paq || []) // Key for localStorage const HIDE_PASTE_WARNING_KEY = 'remixide.hide_paste_warning'; @@ -158,6 +159,8 @@ export interface EditorUIProps { const contextMenuEvent = new EventManager() export const EditorUI = (props: EditorUIProps) => { const intl = useIntl() + const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const changedTypeMap = useRef({}) const pendingCustomDiff = useRef({}) const [, setCurrentBreakpoints] = useState({}) @@ -203,21 +206,24 @@ export const EditorUI = (props: EditorUIProps) => { const formatColor = (name) => { let color = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim() - if (color.length === 4) { + if (color.length === 4 && color.startsWith('#')) { color = color.concat(color.substr(1)) } return color } + const defineAndSetTheme = (monaco) => { const themeType = props.themeType === 'dark' ? 'vs-dark' : 'vs' const themeName = props.themeType === 'dark' ? 'remix-dark' : 'remix-light' + const isDark = props.themeType === 'dark' + // see https://microsoft.github.io/monaco-editor/playground.html#customizing-the-appearence-exposed-colors const lightColor = formatColor('--bs-light') const infoColor = formatColor('--bs-info') const darkColor = formatColor('--bs-dark') const secondaryColor = formatColor('--bs-body-bg') const primaryColor = formatColor('--bs-primary') - const textColor = formatColor('--text') || darkColor + const textColor = formatColor('--bs-body-color') || darkColor const textbackground = formatColor('--bs-body-bg') || lightColor const blueColor = formatColor('--bs-blue') const successColor = formatColor('--bs-success') @@ -345,7 +351,21 @@ export const EditorUI = (props: EditorUIProps) => { useEffect(() => { if (!monacoRef.current) return defineAndSetTheme(monacoRef.current) - }) + }, [props.themeType]) // Only re-run when theme type changes + + // Listen for theme changes to redefine the theme when CSS is loaded + useEffect(() => { + if (!monacoRef.current) return + + const handleThemeChange = () => { + // Small delay to ensure CSS variables are available after theme switch + setTimeout(() => { + defineAndSetTheme(monacoRef.current) + }, 100) + } + + props.plugin.on('theme', 'themeChanged', handleThemeChange) + }, [monacoRef.current]) useEffect(() => { props.plugin.on('fileManager', 'currentFileChanged', (file: string) => { @@ -449,7 +469,7 @@ export const EditorUI = (props: EditorUIProps) => { } }, [props.currentFile, props.isDiff]) - const inlineCompletionProvider = new RemixInLineCompletionProvider(props, monacoRef.current) + const inlineCompletionProvider = new RemixInLineCompletionProvider(props, monacoRef.current, trackMatomoEvent) const convertToMonacoDecoration = (decoration: lineText | sourceAnnotation | sourceMarker, typeOfDecoration: string) => { if (typeOfDecoration === 'sourceAnnotationsPerFile') { @@ -771,7 +791,7 @@ export const EditorUI = (props: EditorUIProps) => { setTimeout(async () => { props.plugin.call('remixAI', 'chatPipe', 'vulnerability_check', pastedCodePrompt) }, 500) - _paq.push(['trackEvent', 'ai', 'remixAI', 'vulnerability_check_pasted_code']) + trackMatomoEvent?.(AIEvents.vulnerabilityCheckPastedCode()) })(); } }; @@ -828,7 +848,7 @@ export const EditorUI = (props: EditorUIProps) => { ) } props.plugin.call('notification', 'modal', modalContent) - _paq.push(['trackEvent', 'editor', 'onDidPaste', 'more_than_10_lines']) + trackMatomoEvent?.(EditorEvents.onDidPaste('more_than_10_lines')) } }) @@ -839,7 +859,7 @@ export const EditorUI = (props: EditorUIProps) => { if (changes.some(change => change.text === inlineCompletionProvider.currentCompletion.item.insertText)) { inlineCompletionProvider.currentCompletion.onAccepted() inlineCompletionProvider.currentCompletion.accepted = true - _paq.push(['trackEvent', 'ai', 'remixAI', 'Copilot_Completion_Accepted']) + trackMatomoEvent?.(AIEvents.copilotCompletionAccepted()) } } }); @@ -975,7 +995,7 @@ export const EditorUI = (props: EditorUIProps) => { }, 150) } } - _paq.push(['trackEvent', 'ai', 'remixAI', 'generateDocumentation']) + trackMatomoEvent?.(AIEvents.generateDocumentation()) }, } } @@ -994,7 +1014,7 @@ export const EditorUI = (props: EditorUIProps) => { setTimeout(async () => { await props.plugin.call('remixAI' as any, 'chatPipe', 'code_explaining', message, context) }, 500) - _paq.push(['trackEvent', 'ai', 'remixAI', 'explainFunction']) + trackMatomoEvent?.(AIEvents.explainFunction()) }, } @@ -1018,7 +1038,7 @@ export const EditorUI = (props: EditorUIProps) => { setTimeout(async () => { await props.plugin.call('remixAI' as any, 'chatPipe', 'code_explaining', selectedCode, content, pipeMessage) }, 500) - _paq.push(['trackEvent', 'ai', 'remixAI', 'explainFunction']) + trackMatomoEvent?.(AIEvents.explainFunction()) }, } diff --git a/libs/remix-ui/git/src/lib/pluginActions.ts b/libs/remix-ui/git/src/lib/pluginActions.ts index 96534a68805..27df71806b8 100644 --- a/libs/remix-ui/git/src/lib/pluginActions.ts +++ b/libs/remix-ui/git/src/lib/pluginActions.ts @@ -103,12 +103,21 @@ export const openFolderInSameWindow = async (path: string) => { await plugin.call('fs', 'openFolderInSameWindow', path) } +import { trackMatomoEvent, GitEvents } from '@remix-api'; + export const openCloneDialog = async () => { plugin.call('filePanel', 'clone') } export const sendToMatomo = async (event: gitMatomoEventTypes, args?: string[]) => { - const trackArgs = args ? ['trackEvent', 'git', event, ...args] : ['trackEvent', 'git', event]; - plugin && await plugin.call('matomo', 'track', trackArgs); + // Map gitMatomoEventTypes to GitEvents dynamically + const eventMethod = GitEvents[event as keyof typeof GitEvents]; + if (typeof eventMethod === 'function') { + const matomoEvent = args && args.length > 0 + ? eventMethod(args[0], args[1]) + : eventMethod(); + plugin && trackMatomoEvent(plugin, matomoEvent); + } + // Note: No legacy fallback - all events must use type-safe format } export const loginWithGitHub = async () => { diff --git a/libs/remix-ui/grid-view/src/lib/remix-ui-grid-cell.tsx b/libs/remix-ui/grid-view/src/lib/remix-ui-grid-cell.tsx index 36b0afe9beb..b722eb1c960 100644 --- a/libs/remix-ui/grid-view/src/lib/remix-ui-grid-cell.tsx +++ b/libs/remix-ui/grid-view/src/lib/remix-ui-grid-cell.tsx @@ -5,13 +5,6 @@ import FiltersContext from "./filtersContext" import { CustomTooltip } from '@remix-ui/helper' import { ChildCallbackContext } from './remix-ui-grid-section' -declare global { - interface Window { - _paq: any - } -} -const _paq = window._paq = window._paq || [] - interface RemixUIGridCellProps { plugin: any pinned?: boolean diff --git a/libs/remix-ui/grid-view/src/lib/remix-ui-grid-section.tsx b/libs/remix-ui/grid-view/src/lib/remix-ui-grid-section.tsx index 62d85a5c177..9be4a881ca9 100644 --- a/libs/remix-ui/grid-view/src/lib/remix-ui-grid-section.tsx +++ b/libs/remix-ui/grid-view/src/lib/remix-ui-grid-section.tsx @@ -1,11 +1,5 @@ import React, {createContext, ReactNode, useEffect, useState} from 'react' // eslint-disable-line import './remix-ui-grid-section.css' -declare global { - interface Window { - _paq: any - } -} -const _paq = window._paq = window._paq || [] // Define the type for the context value interface ChildCallbackContextType { diff --git a/libs/remix-ui/grid-view/src/lib/remix-ui-grid-view.tsx b/libs/remix-ui/grid-view/src/lib/remix-ui-grid-view.tsx index 258526f8984..b33b3516283 100644 --- a/libs/remix-ui/grid-view/src/lib/remix-ui-grid-view.tsx +++ b/libs/remix-ui/grid-view/src/lib/remix-ui-grid-view.tsx @@ -1,15 +1,9 @@ import React, {useState, useEffect, useContext, useRef, ReactNode} from 'react' // eslint-disable-line - import './remix-ui-grid-view.css' import CustomCheckbox from './components/customCheckbox' import FiltersContext from "./filtersContext" - -declare global { - interface Window { - _paq: any - } -} -const _paq = window._paq = window._paq || [] +import { GridViewEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' interface RemixUIGridViewProps { plugin: any @@ -30,6 +24,7 @@ export const RemixUIGridView = (props: RemixUIGridViewProps) => { const [filter, setFilter] = useState("") const showUntagged = props.showUntagged || false const showPin = props.showPin || false + const { trackMatomoEvent } = useContext(TrackingContext) const updateValue = (key: string, enabled: boolean, color?: string) => { if (!color || color === '') color = setKeyValueMap[key].color setKeyValueMap((prevMap) => ({ @@ -112,7 +107,7 @@ export const RemixUIGridView = (props: RemixUIGridViewProps) => { className="remixui_grid_view_btn text-secondary form-control bg-light border d-flex align-items-center p-2 justify-content-center fas fa-filter bg-light" onClick={(e) => { setFilter(searchInputRef.current.value) - _paq.push(['trackEvent', 'GridView' + props.title ? props.title : '', 'filter', searchInputRef.current.value]) + trackMatomoEvent?.(GridViewEvents.filterWithTitle(props.title || '', searchInputRef.current.value)) }} > For more details,  _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'goToSolidityScan'])}> + onClick={() => trackMatomoEvent?.(SolidityCompilerEvents.solidityScan('goToSolidityScan'))}> go to SolidityScan.

diff --git a/libs/remix-ui/helper/src/lib/solidity-scan.tsx b/libs/remix-ui/helper/src/lib/solidity-scan.tsx index 00afaaff441..eae0665dc58 100644 --- a/libs/remix-ui/helper/src/lib/solidity-scan.tsx +++ b/libs/remix-ui/helper/src/lib/solidity-scan.tsx @@ -3,12 +3,14 @@ import axios from 'axios' import { FormattedMessage } from 'react-intl' import { endpointUrls } from '@remix-endpoints-helper' import { ScanReport, SolScanTable } from '@remix-ui/helper' +import { trackMatomoEvent, SolidityCompilerEvents } from '@remix-api' -const _paq = (window._paq = window._paq || []) +import { CopyToClipboard } from '@remix-ui/clipboard' +import { CustomTooltip } from './components/custom-tooltip' export const handleSolidityScan = async (api: any, compiledFileName: string) => { await api.call('notification', 'toast', 'Processing data to scan...') - _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'initiateScan']) + await trackMatomoEvent(api, SolidityCompilerEvents.solidityScan('initiateScan')) const workspace = await api.call('filePanel', 'getCurrentWorkspace') const fileName = `${workspace.name}/${compiledFileName}` @@ -41,7 +43,7 @@ export const handleSolidityScan = async (api: any, compiledFileName: string) => } })) } else if (data.type === "scan_status" && data.payload.scan_status === "download_failed") { - _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'scanFailed']) + await trackMatomoEvent(api, SolidityCompilerEvents.solidityScan('scanFailed')) await api.call('notification', 'modal', { id: 'SolidityScanError', title: , @@ -50,7 +52,7 @@ export const handleSolidityScan = async (api: any, compiledFileName: string) => }) ws.close() } else if (data.type === "scan_status" && data.payload.scan_status === "scan_done") { - _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'scanSuccess']) + await trackMatomoEvent(api, SolidityCompilerEvents.solidityScan('scanSuccess')) const { data: scanData } = await axios.post(`${endpointUrls.solidityScan}/downloadResult`, { url: data.payload.scan_details.link }) const scanReport: ScanReport = scanData.scan_report diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabFeatured.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabFeatured.tsx index 2232172fa55..ad7256f3548 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabFeatured.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabFeatured.tsx @@ -2,24 +2,38 @@ import React, { useEffect, useState, useRef, useContext } from 'react' import { FormattedMessage } from 'react-intl' import { ThemeContext, themes } from '../themeContext' +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' import Carousel from 'react-multi-carousel' import 'react-multi-carousel/lib/styles.css' -import * as releaseDetails from './../../../../../../releaseDetails.json' +// import * as releaseDetails from './../../../../../../releaseDetails.json' + +// Temporary fallback for missing releaseDetails.json +const releaseDetails = { + version: 'Latest', + title: 'Release', + highlight1: 'New features and improvements', + highlight2: 'Bug fixes and stability', + highlight3: 'Enhanced user experience', + highlight4: 'Better performance', + moreLink: '#', + more: 'Learn more' +} -const _paq = (window._paq = window._paq || []) // eslint-disable-line export type HomeTabFeaturedProps = { plugin: any } function HomeTabFeatured(props:HomeTabFeaturedProps) { const themeFilter = useContext(ThemeContext) + const { trackMatomoEvent } = useContext(TrackingContext) const handleStartLearneth = async () => { await props.plugin.appManager.activatePlugin(['LearnEth', 'solidityUnitTesting']) props.plugin.verticalIcons.select('LearnEth') - _paq.push(['trackEvent', 'hometab', 'featuredSection', 'LearnEth']) + trackMatomoEvent?.(HomeTabEvents.featuredSection('LearnEth')) } const handleStartRemixGuide = async () => { - _paq.push(['trackEvent', 'hometab', 'featuredSection', 'watchOnRemixGuide']) + trackMatomoEvent?.(HomeTabEvents.featuredSection('watchOnRemixGuide')) await props.plugin.appManager.activatePlugin(['remixGuide']) await props.plugin.call('tabs', 'focus', 'remixGuide') } @@ -64,7 +78,7 @@ function HomeTabFeatured(props:HomeTabFeaturedProps) { Please take a few minutes of your time to _paq.push(['trackEvent', 'hometab', 'featuredSection', 'soliditySurvey24'])} + onClick={() => trackMatomoEvent?.(HomeTabEvents.featuredSection('soliditySurvey24'))} target="__blank" href="https://cryptpad.fr/form/#/2/form/view/9xjPVmdv8z0Cyyh1ejseMQ0igmx-TedH5CPST3PhRUk/" > @@ -75,7 +89,7 @@ function HomeTabFeatured(props:HomeTabFeaturedProps) { Thank you for your support! Read the full announcement _paq.push(['trackEvent', 'hometab', 'featuredSection', 'soliditySurvey24'])} + onClick={() => trackMatomoEvent?.(HomeTabEvents.featuredSection('soliditySurvey24'))} target="__blank" href="https://soliditylang.org/blog/2024/12/27/solidity-developer-survey-2024-announcement/" > @@ -100,7 +114,7 @@ function HomeTabFeatured(props:HomeTabFeaturedProps) { _paq.push(['trackEvent', 'hometab', 'featuredSection', 'seeFullChangelog'])} + onClick={() => trackMatomoEvent?.(HomeTabEvents.featuredSection('seeFullChangelog'))} target="__blank" href={releaseDetails.moreLink} > diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabFeaturedPlugins.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabFeaturedPlugins.tsx index baf75168603..4f7c64d5cfc 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabFeaturedPlugins.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabFeaturedPlugins.tsx @@ -7,12 +7,9 @@ import { FormattedMessage } from 'react-intl' import { HOME_TAB_PLUGIN_LIST } from './constant' import axios from 'axios' import { LoadingCard } from './LoaderPlaceholder' -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) //eslint-disable-line +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' + interface HomeTabFeaturedPluginsProps { plugin: any } @@ -39,6 +36,7 @@ function HomeTabFeaturedPlugins({ plugin }: HomeTabFeaturedPluginsProps) { const [pluginList, setPluginList] = useState<{ caption: string, plugins: PluginInfo[] }>({ caption: '', plugins: []}) const [isLoading, setIsLoading] = useState(true) const theme = useContext(ThemeContext) + const { trackMatomoEvent } = useContext(TrackingContext) const isDark = theme.name === 'dark' useEffect(() => { @@ -63,11 +61,11 @@ function HomeTabFeaturedPlugins({ plugin }: HomeTabFeaturedPluginsProps) { const activateFeaturedPlugin = async (pluginId: string) => { setLoadingPlugins([...loadingPlugins, pluginId]) if (await plugin.appManager.isActive(pluginId)) { - _paq.push(['trackEvent', 'hometab', 'featuredPluginsToggle', `deactivate-${pluginId}`]) + trackMatomoEvent?.(HomeTabEvents.featuredPluginsToggle(`deactivate-${pluginId}`)) await plugin.appManager.deactivatePlugin(pluginId) setActivePlugins(activePlugins.filter((id) => id !== pluginId)) } else { - _paq.push(['trackEvent', 'hometab', 'featuredPluginsToggle', `activate-${pluginId}`]) + trackMatomoEvent?.(HomeTabEvents.featuredPluginsToggle(`activate-${pluginId}`)) await plugin.appManager.activatePlugin([pluginId]) await plugin.verticalIcons.select(pluginId) setActivePlugins([...activePlugins, pluginId]) @@ -76,7 +74,7 @@ function HomeTabFeaturedPlugins({ plugin }: HomeTabFeaturedPluginsProps) { } const handleFeaturedPluginActionClick = async (pluginInfo: PluginInfo) => { - _paq.push(['trackEvent', 'hometab', 'featuredPluginsActionClick', pluginInfo.pluginTitle]) + trackMatomoEvent?.(HomeTabEvents.featuredPluginsActionClick(pluginInfo.pluginTitle)) if (pluginInfo.action.type === 'link') { window.open(pluginInfo.action.url, '_blank') } else if (pluginInfo.action.type === 'methodCall') { diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabFile.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabFile.tsx index a5276300874..762a2616562 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabFile.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabFile.tsx @@ -1,15 +1,17 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import React, { useState, useRef, useReducer, useEffect } from 'react' +import React, { useState, useRef, useReducer, useEffect, useContext } from 'react' import { FormattedMessage } from 'react-intl' import {Toaster} from '@remix-ui/toaster' // eslint-disable-line -const _paq = (window._paq = window._paq || []) // eslint-disable-line import { CustomTooltip } from '@remix-ui/helper' +import { TrackingContext } from '@remix-ide/tracking' +import { HomeTabEvents } from '@remix-api' interface HomeTabFileProps { plugin: any } function HomeTabFile({ plugin }: HomeTabFileProps) { + const { trackMatomoEvent } = useContext(TrackingContext) const [state, setState] = useState<{ searchInput: string showModalDialog: boolean @@ -80,7 +82,7 @@ function HomeTabFile({ plugin }: HomeTabFileProps) { } const startCoding = async () => { - _paq.push(['trackEvent', 'hometab', 'filesSection', 'startCoding']) + trackMatomoEvent?.(HomeTabEvents.filesSection('startCoding')) plugin.verticalIcons.select('filePanel') const wName = 'Playground' @@ -113,16 +115,16 @@ function HomeTabFile({ plugin }: HomeTabFileProps) { } const uploadFile = async (target) => { - _paq.push(['trackEvent', 'hometab', 'filesSection', 'uploadFile']) + trackMatomoEvent?.(HomeTabEvents.filesSection('uploadFile')) await plugin.call('filePanel', 'uploadFile', target) } const connectToLocalhost = () => { - _paq.push(['trackEvent', 'hometab', 'filesSection', 'connectToLocalhost']) + trackMatomoEvent?.(HomeTabEvents.filesSection('connectToLocalhost')) plugin.appManager.activatePlugin('remixd') } const importFromGist = () => { - _paq.push(['trackEvent', 'hometab', 'filesSection', 'importFromGist']) + trackMatomoEvent?.(HomeTabEvents.filesSection('importFromGist')) plugin.call('gistHandler', 'load', '') plugin.verticalIcons.select('filePanel') } @@ -131,7 +133,7 @@ function HomeTabFile({ plugin }: HomeTabFileProps) { e.preventDefault() plugin.call('sidePanel', 'showContent', 'filePanel') plugin.verticalIcons.select('filePanel') - _paq.push(['trackEvent', 'hometab', 'filesSection', 'loadRecentWorkspace']) + trackMatomoEvent?.(HomeTabEvents.filesSection('loadRecentWorkspace')) await plugin.call('filePanel', 'switchToWorkspace', { name: workspaceName, isLocalhost: false }) } @@ -170,7 +172,7 @@ function HomeTabFile({ plugin }: HomeTabFileProps) {
} tooltipTextClasses="border bg-light text-dark p-1 pe-3">
) -} \ No newline at end of file +} + +export { HomeTabFileElectron } \ No newline at end of file diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabGetStarted.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabGetStarted.tsx index b414b7a7cb3..236dead1519 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabGetStarted.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabGetStarted.tsx @@ -5,17 +5,13 @@ import { TEMPLATE_NAMES, TEMPLATE_METADATA } from '@remix-ui/workspace' import { ThemeContext } from '../themeContext' import WorkspaceTemplate from './workspaceTemplate' import 'react-multi-carousel/lib/styles.css' -import { appPlatformTypes, platformContext } from '@remix-ui/app' +import { AppContext, appPlatformTypes, platformContext } from '@remix-ui/app' +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' import { Plugin } from "@remixproject/engine"; import { CustomRemixApi } from '@remix-api' import { CustomTooltip } from '@remix-ui/helper' -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) //eslint-disable-line interface HomeTabGetStartedProps { plugin: any } @@ -76,6 +72,8 @@ const workspaceTemplates: WorkspaceTemplate[] = [ function HomeTabGetStarted({ plugin }: HomeTabGetStartedProps) { const platform = useContext(platformContext) const themeFilter = useContext(ThemeContext) + const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const intl = useIntl() const carouselRef = useRef({}) const carouselRefDiv = useRef(null) @@ -147,7 +145,7 @@ function HomeTabGetStarted({ plugin }: HomeTabGetStartedProps) { await plugin.call('filePanel', 'setWorkspace', templateDisplayName) plugin.verticalIcons.select('filePanel') } - _paq.push(['trackEvent', 'hometab', 'homeGetStarted', templateName]) + trackMatomoEvent?.(HomeTabEvents.homeGetStarted(templateName)) } return ( @@ -178,7 +176,7 @@ function HomeTabGetStarted({ plugin }: HomeTabGetStartedProps) { } onClick={async (e) => { createWorkspace(template.templateName) - _paq.push(['trackEvent', 'hometab', 'homeGetStarted', template.templateName]) + trackMatomoEvent?.(HomeTabEvents.homeGetStarted(template.templateName)) }} data-id={`homeTabGetStarted${template.templateName}`} > diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabLearn.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabLearn.tsx index 32250dce623..b79478e7b5a 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabLearn.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabLearn.tsx @@ -3,12 +3,8 @@ import React, { useEffect, useState, useContext } from 'react' import { FormattedMessage } from 'react-intl' import { ThemeContext } from '../themeContext' import { CustomTooltip } from '@remix-ui/helper' -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) //eslint-disable-line +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' enum VisibleTutorial { Basics, @@ -27,12 +23,13 @@ function HomeTabLearn({ plugin }: HomeTabLearnProps) { }) const themeFilter = useContext(ThemeContext) + const { trackMatomoEvent } = useContext(TrackingContext) const startLearnEthTutorial = async (tutorial: 'basics' | 'soliditybeginner' | 'deploylibraries') => { await plugin.appManager.activatePlugin(['solidity', 'LearnEth', 'solidityUnitTesting']) plugin.verticalIcons.select('LearnEth') plugin.call('LearnEth', 'startTutorial', 'remix-project-org/remix-workshops', 'master', tutorial) - _paq.push(['trackEvent', 'hometab', 'startLearnEthTutorial', tutorial]) + trackMatomoEvent?.(HomeTabEvents.startLearnEthTutorial(tutorial)) } const goToLearnEthHome = async () => { diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabRecentWorkspaces.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabRecentWorkspaces.tsx index 3866e84fa71..755328739d4 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabRecentWorkspaces.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabRecentWorkspaces.tsx @@ -1,14 +1,16 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import React, { useState, useRef, useReducer, useEffect, useContext } from 'react' import { ThemeContext } from '../themeContext' +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' import { getTimeAgo } from '@remix-ui/helper' -const _paq = (window._paq = window._paq || []) // eslint-disable-line interface HomeTabFileProps { plugin: any } function HomeTabRecentWorkspaces({ plugin }: HomeTabFileProps) { + const { trackMatomoEvent } = useContext(TrackingContext) const [state, setState] = useState<{ recentWorkspaces: Array }>({ @@ -62,7 +64,7 @@ function HomeTabRecentWorkspaces({ plugin }: HomeTabFileProps) { setLoadingWorkspace(workspaceName) plugin.call('sidePanel', 'showContent', 'filePanel') plugin.verticalIcons.select('filePanel') - _paq.push(['trackEvent', 'hometab', 'recentWorkspacesCard', 'loadRecentWorkspace']) + trackMatomoEvent?.(HomeTabEvents.recentWorkspacesCard('loadRecentWorkspace')) await plugin.call('filePanel', 'switchToWorkspace', { name: workspaceName, isLocalhost: false }) const workspaceFiles = await plugin.call('fileManager', 'readdir', '/') diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabScamAlert.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabScamAlert.tsx index 31c89aa5706..d42c4ae7a53 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabScamAlert.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabScamAlert.tsx @@ -1,12 +1,14 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { appPlatformTypes, platformContext } from '@remix-ui/app' +import { AppContext, appPlatformTypes, platformContext } from '@remix-ui/app' +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' import React, { useContext } from 'react' import { FormattedMessage } from 'react-intl' -const _paq = (window._paq = window._paq || []) // eslint-disable-line - function HomeTabScamAlert() { const platform = useContext(platformContext) + const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) return (
_paq.push(['trackEvent', 'hometab', 'scamAlert', 'learnMore'])} + onClick={() => trackMatomoEvent?.(HomeTabEvents.scamAlert('learnMore'))} target="__blank" href="https://medium.com/remix-ide/remix-in-youtube-crypto-scams-71c338da32d" > @@ -36,7 +38,7 @@ function HomeTabScamAlert() { :   _paq.push(['trackEvent', 'hometab', 'scamAlert', 'safetyTips'])} + onClick={() => trackMatomoEvent?.(HomeTabEvents.scamAlert('safetyTips'))} target="__blank" href="https://remix-ide.readthedocs.io/en/latest/security.html" > diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabTitle.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabTitle.tsx index a7044272bea..18955883b7a 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabTitle.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabTitle.tsx @@ -4,9 +4,10 @@ import React, { useRef, useContext } from 'react' import { FormattedMessage } from 'react-intl' import { CustomTooltip } from '@remix-ui/helper' import { ThemeContext } from '../themeContext' +import { TrackingContext } from '@remix-ide/tracking' +import { HomeTabEvents } from '@remix-api' import { Placement } from 'react-bootstrap/esm/types' import { DesktopDownload } from 'libs/remix-ui/desktop-download' // eslint-disable-line @nrwl/nx/enforce-module-boundaries -const _paq = (window._paq = window._paq || []) // eslint-disable-line type HometabIconSection = { textToolip: JSX.Element @@ -57,11 +58,12 @@ const iconButtons: HometabIconSection[] = [ function HomeTabTitle() { const remiAudioEl = useRef(null) const theme = useContext(ThemeContext) + const { trackMatomoEvent } = useContext(TrackingContext) const isDark = theme.name === 'dark' const playRemi = async () => { remiAudioEl.current.play() - _paq.push(['trackEvent', 'hometab', 'titleCard', 'remiAudio']) + trackMatomoEvent?.(HomeTabEvents.titleCard('remiAudio')) } const openLink = (url = '') => { @@ -104,7 +106,7 @@ function HomeTabTitle() { key={index} onClick={() => { openLink(button.urlLink) - _paq.push(button.matomoTrackingEntry) + trackMatomoEvent?.(HomeTabEvents.titleCard(button.matomoTrackingEntry[3])) }} className={`border-0 h-100 px-1 btn fab ${button.iconClass} text-dark`} > @@ -113,8 +115,8 @@ function HomeTabTitle() {
diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabUpdates.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabUpdates.tsx index 6b900adcaa4..a52aab2ddc3 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTabUpdates.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabUpdates.tsx @@ -5,12 +5,11 @@ import axios from 'axios' import { HOME_TAB_BASE_URL, HOME_TAB_NEW_UPDATES } from './constant' import { LoadingCard } from './LoaderPlaceholder' import { UpdateInfo } from './types/carouselTypes' -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) //eslint-disable-line +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' + +import { CustomTooltip } from '@remix-ui/helper' +import { FormattedMessage } from 'react-intl' interface HomeTabUpdatesProps { plugin: any } @@ -36,6 +35,7 @@ function HomeTabUpdates({ plugin }: HomeTabUpdatesProps) { const [pluginList, setPluginList] = useState([]) const [isLoading, setIsLoading] = useState(true) const theme = useContext(ThemeContext) + const { trackMatomoEvent } = useContext(TrackingContext) const isDark = theme.name === 'dark' useEffect(() => { @@ -53,7 +53,7 @@ function HomeTabUpdates({ plugin }: HomeTabUpdatesProps) { }, []) const handleUpdatesActionClick = (updateInfo: UpdateInfo) => { - _paq.push(['trackEvent', 'hometab', 'updatesActionClick', updateInfo.title]) + trackMatomoEvent?.(HomeTabEvents.updatesActionClick(updateInfo.title)) if (updateInfo.action.type === 'link') { window.open(updateInfo.action.url, '_blank') } else if (updateInfo.action.type === 'methodCall') { diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTablangOptions.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTablangOptions.tsx index 46642c499e8..fabe3268abb 100644 --- a/libs/remix-ui/home-tab/src/lib/components/homeTablangOptions.tsx +++ b/libs/remix-ui/home-tab/src/lib/components/homeTablangOptions.tsx @@ -1,12 +1,14 @@ -import React, { useEffect, useState } from 'react' +import React, { useContext, useEffect, useState } from 'react' import { Dropdown, DropdownButton } from 'react-bootstrap' import DropdownItem from 'react-bootstrap/DropdownItem' import { localeLang } from './types/carouselTypes' import { FormattedMessage } from 'react-intl' -const _paq = (window._paq = window._paq || []) +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' export function LanguageOptions({ plugin }: { plugin: any }) { const [langOptions, setLangOptions] = useState() + const { trackMatomoEvent } = useContext(TrackingContext) const changeLanguage = async (lang: string) => { await plugin.call('locale', 'switchLocale', lang) @@ -40,7 +42,7 @@ export function LanguageOptions({ plugin }: { plugin: any }) { { changeLanguage(lang.toLowerCase()) setLangOptions(lang) - _paq.push(['trackEvent', 'hometab', 'switchTo', lang]) + trackMatomoEvent?.(HomeTabEvents.switchTo(lang)) }} style={{ color: 'var(--text)', cursor: 'pointer' }} key={index} diff --git a/libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx b/libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx index 4f47bd6cbd7..830b0ca94d7 100644 --- a/libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx +++ b/libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx @@ -6,28 +6,23 @@ import HomeTabRecentWorkspaces from './components/homeTabRecentWorkspaces' import HomeTabScamAlert from './components/homeTabScamAlert' import HomeTabFeaturedPlugins from './components/homeTabFeaturedPlugins' import { AppContext, appPlatformTypes, platformContext } from '@remix-ui/app' +import { HomeTabEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' import { HomeTabFileElectron } from './components/homeTabFileElectron' import HomeTabUpdates from './components/homeTabUpdates' import { FormattedMessage } from 'react-intl' // import { desktopConnectionType } from '@remix-api' import { desktopConnectionType } from '@remix-api' -declare global { - interface Window { - _paq: any - } -} - export interface RemixUiHomeTabProps { plugin: any } -const _paq = (window._paq = window._paq || []) // eslint-disable-line - // --- Main Layout --- export const RemixUiHomeTab = (props: RemixUiHomeTabProps) => { const platform = useContext(platformContext) const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const { plugin } = props const [state, setState] = useState<{ @@ -64,13 +59,13 @@ export const RemixUiHomeTab = (props: RemixUiHomeTabProps) => { await plugin.appManager.activatePlugin(['LearnEth', 'solidity', 'solidityUnitTesting']) plugin.verticalIcons.select('LearnEth') } - _paq.push(['trackEvent', 'hometab', 'header', 'Start Learning']) + trackMatomoEvent?.(HomeTabEvents.header('Start Learning')) } const openTemplateSelection = async () => { await plugin.call('manager', 'activatePlugin', 'templateSelection') await plugin.call('tabs', 'focus', 'templateSelection') - _paq.push(['trackEvent', 'hometab', 'header', 'Create a new workspace']) + trackMatomoEvent?.(HomeTabEvents.header('Create a new workspace')) } // if (appContext.appState.connectedToDesktop != desktopConnectionType.disabled) { diff --git a/libs/remix-ui/locale-module/types/locale-module.ts b/libs/remix-ui/locale-module/types/locale-module.ts index 824f68147cc..df125f5834e 100644 --- a/libs/remix-ui/locale-module/types/locale-module.ts +++ b/libs/remix-ui/locale-module/types/locale-module.ts @@ -8,7 +8,7 @@ export interface LocaleModule extends Plugin { _deps: { config: any; }; - _paq: any + element: HTMLDivElement; locales: {[key: string]: Locale}; active: string; diff --git a/libs/remix-ui/panel/src/lib/plugins/panel-header.tsx b/libs/remix-ui/panel/src/lib/plugins/panel-header.tsx index d605f100118..56daa783143 100644 --- a/libs/remix-ui/panel/src/lib/plugins/panel-header.tsx +++ b/libs/remix-ui/panel/src/lib/plugins/panel-header.tsx @@ -1,9 +1,10 @@ -import React, {useEffect, useState} from 'react' // eslint-disable-line +import React, {useEffect, useState, useContext} from 'react' // eslint-disable-line import { FormattedMessage } from 'react-intl' import { PluginRecord } from '../types' import './panel.css' import { CustomTooltip, RenderIf, RenderIfNot } from '@remix-ui/helper' -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { PluginPanelEvents } from '@remix-api' export interface RemixPanelProps { plugins: Record, @@ -15,6 +16,7 @@ export interface RemixPanelProps { const RemixUIPanelHeader = (props: RemixPanelProps) => { const [plugin, setPlugin] = useState() const [toggleExpander, setToggleExpander] = useState(false) + const { trackMatomoEvent } = useContext(TrackingContext) useEffect(() => { setToggleExpander(false) @@ -32,12 +34,12 @@ const RemixUIPanelHeader = (props: RemixPanelProps) => { const pinPlugin = () => { props.pinView && props.pinView(plugin.profile, plugin.view) - _paq.push(['trackEvent', 'PluginPanel', 'pinToRight', plugin.profile.name]) + trackMatomoEvent?.(PluginPanelEvents.pinToRight(plugin.profile.name)) } const unPinPlugin = () => { props.unPinView && props.unPinView(plugin.profile) - _paq.push(['trackEvent', 'PluginPanel', 'pinToLeft', plugin.profile.name]) + trackMatomoEvent?.(PluginPanelEvents.pinToLeft(plugin.profile.name)) } const closePlugin = async () => { diff --git a/libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx b/libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx index 7c895dc7080..77d9d5b9a99 100644 --- a/libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx +++ b/libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx @@ -1,9 +1,11 @@ import { ActivityType } from "../lib/types" -import React, { MutableRefObject, Ref, useEffect, useRef, useState } from 'react' +import React, { MutableRefObject, Ref, useContext, useEffect, useRef, useState } from 'react' import GroupListMenu from "./contextOptMenu" import { AiContextType, groupListType } from '../types/componentTypes' import { AiAssistantType } from '../types/componentTypes' import { CustomTooltip } from "@remix-ui/helper" +import { RemixAIEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' // PromptArea component export interface PromptAreaProps { @@ -42,8 +44,6 @@ export interface PromptAreaProps { setAiMode: React.Dispatch> } -const _paq = (window._paq = window._paq || []) - export const PromptArea: React.FC = ({ input, setInput, @@ -79,6 +79,7 @@ export const PromptArea: React.FC = ({ aiMode, setAiMode }) => { + const { trackMatomoEvent } = useContext(TrackingContext) return ( <> @@ -121,7 +122,7 @@ export const PromptArea: React.FC = ({ className={`btn btn-sm ${aiMode === 'ask' ? 'btn-primary' : 'btn-outline-secondary'} px-2`} onClick={() => { setAiMode('ask') - _paq.push(['trackEvent', 'remixAI', 'ModeSwitch', 'ask']) + trackMatomoEvent?.(RemixAIEvents.ModeSwitch('ask')) }} title="Ask mode - Chat with AI" > @@ -132,7 +133,7 @@ export const PromptArea: React.FC = ({ className={`btn btn-sm ${aiMode === 'edit' ? 'btn-primary' : 'btn-outline-secondary'} px-2`} onClick={() => { setAiMode('edit') - _paq.push(['trackEvent', 'remixAI', 'ModeSwitch', 'edit']) + trackMatomoEvent?.(RemixAIEvents.ModeSwitch('edit')) }} title="Edit mode - Edit workspace code" > diff --git a/libs/remix-ui/remix-ai-assistant/src/components/remix-ui-remix-ai-assistant.tsx b/libs/remix-ui/remix-ai-assistant/src/components/remix-ui-remix-ai-assistant.tsx index 8f3f1abbeef..21839aa0e67 100644 --- a/libs/remix-ui/remix-ai-assistant/src/components/remix-ui-remix-ai-assistant.tsx +++ b/libs/remix-ui/remix-ai-assistant/src/components/remix-ui-remix-ai-assistant.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef, useImperativeHandle, MutableRefObject } from 'react' +import React, { useState, useEffect, useCallback, useRef, useImperativeHandle, MutableRefObject, useContext } from 'react' import '../css/remix-ai-assistant.css' import { ChatCommandParser, GenerationParams, ChatHistory, HandleStreamResponse, listModels, isOllamaAvailable } from '@remix/remix-ai-core' @@ -6,6 +6,8 @@ import { HandleOpenAIResponse, HandleMistralAIResponse, HandleAnthropicResponse, import '../css/color.css' import { Plugin } from '@remixproject/engine' import { ModalTypes } from '@remix-ui/app' +import { AIEvents, RemixAIEvents, RemixAIAssistantEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' import { PromptArea } from './prompt' import { ChatHistoryComponent } from './chat' import { ActivityType, ChatMessage } from '../lib/types' @@ -13,8 +15,6 @@ import { groupListType } from '../types/componentTypes' import GroupListMenu from './contextOptMenu' import { useOnClickOutside } from './onClickOutsideHook' -const _paq = (window._paq = window._paq || []) - export interface RemixUiRemixAiAssistantProps { plugin: Plugin queuedMessage: { text: string; timestamp: number } | null @@ -48,6 +48,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< const [contextChoice, setContextChoice] = useState<'none' | 'current' | 'opened' | 'workspace'>( 'none' ) + const { trackMatomoEvent } = useContext(TrackingContext) const [availableModels, setAvailableModels] = useState([]) const [selectedModel, setSelectedModel] = useState(null) const [isOllamaFailureFallback, setIsOllamaFailureFallback] = useState(false) @@ -154,7 +155,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< break case 'current': { - _paq.push(['trackEvent', 'ai', 'remixAI', 'AddingAIContext', choice]) + trackMatomoEvent?.(AIEvents.AddingAIContext(choice)) const f = await props.plugin.call('fileManager', 'getCurrentFile') if (f) files = [f] await props.plugin.call('remixAI', 'setContextFiles', { context: 'currentFile' }) @@ -162,7 +163,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< break case 'opened': { - _paq.push(['trackEvent', 'ai', 'remixAI', 'AddingAIContext', choice]) + trackMatomoEvent?.(AIEvents.AddingAIContext(choice)) const res = await props.plugin.call('fileManager', 'getOpenedFiles') if (Array.isArray(res)) { files = res @@ -174,7 +175,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< break case 'workspace': { - _paq.push(['trackEvent', 'ai', 'remixAI', 'AddingAIContext', choice]) + trackMatomoEvent?.(AIEvents.AddingAIContext(choice)) await props.plugin.call('remixAI', 'setContextFiles', { context: 'workspace' }) files = ['@workspace'] } @@ -247,9 +248,9 @@ export const RemixUiRemixAiAssistant = React.forwardRef< prev.map(m => (m.id === msgId ? { ...m, sentiment: next } : m)) ) if (next === 'like') { - ; (window as any)._paq?.push(['trackEvent', 'remixai-assistant', 'like-response']) + trackMatomoEvent?.(RemixAIAssistantEvents.likeResponse()) } else if (next === 'dislike') { - ; (window as any)._paq?.push(['trackEvent', 'remixai-assistant', 'dislike-response']) + trackMatomoEvent?.(RemixAIAssistantEvents.dislikeResponse()) } } @@ -431,7 +432,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< dispatchActivity('button', 'generateWorkspace') if (prompt && prompt.trim()) { await sendPrompt(`/workspace ${prompt.trim()}`) - _paq.push(['trackEvent', 'remixAI', 'GenerateNewAIWorkspaceFromEditMode', prompt]) + trackMatomoEvent?.(RemixAIEvents.GenerateNewAIWorkspaceFromEditMode(prompt)) } }, [sendPrompt]) @@ -468,14 +469,14 @@ export const RemixUiRemixAiAssistant = React.forwardRef< dispatchActivity('button', 'setAssistant') setMessages([]) sendPrompt(`/setAssistant ${assistantChoice}`) - _paq.push(['trackEvent', 'remixAI', 'SetAIProvider', assistantChoice]) + trackMatomoEvent?.(RemixAIEvents.SetAIProvider(assistantChoice)) // Log specific Ollama selection if (assistantChoice === 'ollama') { - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_provider_selected', `from:${choiceSetting || 'unknown'}`]) + trackMatomoEvent?.(AIEvents.ollamaProviderSelected(`from:${choiceSetting || 'unknown'}`)) } } else { // This is a fallback, just update the backend silently - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_fallback_to_provider', `${assistantChoice}|from:${choiceSetting}`]) + trackMatomoEvent?.(AIEvents.ollamaFallbackToProvider(`${assistantChoice}|from:${choiceSetting}`)) await props.plugin.call('remixAI', 'setAssistantProvider', assistantChoice) } setAssistantChoice(assistantChoice || 'mistralai') @@ -511,7 +512,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< if (!selectedModel && models.length > 0) { const defaultModel = models.find(m => m.includes('codestral')) || models[0] setSelectedModel(defaultModel) - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_default_model_selected', `${defaultModel}|codestral|total:${models.length}`]) + trackMatomoEvent?.(AIEvents.ollamaDefaultModelSelected(`${defaultModel}|codestral|total:${models.length}`)) // Sync the default model with the backend try { await props.plugin.call('remixAI', 'setModel', defaultModel) @@ -538,7 +539,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< sentiment: 'none' }]) // Log Ollama unavailable event - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_unavailable', 'switching_to_mistralai']) + trackMatomoEvent?.(AIEvents.ollamaUnavailable('switching_to_mistralai')) // Set failure flag before switching back to prevent success message setIsOllamaFailureFallback(true) // Automatically switch back to mistralai @@ -555,7 +556,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< sentiment: 'none' }]) // Log Ollama connection error - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_connection_error', `${error.message || 'unknown'}|switching_to_mistralai`]) + trackMatomoEvent?.(AIEvents.ollamaConnectionError(`${error.message || 'unknown'}|switching_to_mistralai`)) // Set failure flag before switching back to prevent success message setIsOllamaFailureFallback(true) // Switch back to mistralai on error @@ -578,16 +579,16 @@ export const RemixUiRemixAiAssistant = React.forwardRef< const previousModel = selectedModel setSelectedModel(modelName) setShowModelOptions(false) - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_model_selected', `${modelName}|from:${previousModel || 'none'}`]) + trackMatomoEvent?.(AIEvents.ollamaModelSelected(`${modelName}|from:${previousModel || 'none'}`)) // Update the model in the backend try { await props.plugin.call('remixAI', 'setModel', modelName) - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_model_set_backend_success', modelName]) + trackMatomoEvent?.(AIEvents.ollamaModelSetBackendSuccess(modelName)) } catch (error) { console.warn('Failed to set model:', error) - _paq.push(['trackEvent', 'ai', 'remixAI', 'ollama_model_set_backend_failed', `${modelName}|${error.message || 'unknown'}`]) + trackMatomoEvent?.(AIEvents.ollamaModelSetBackendFailed(`${modelName}|${error.message || 'unknown'}`)) } - _paq.push(['trackEvent', 'remixAI', 'SetOllamaModel', modelName]) + trackMatomoEvent?.(RemixAIEvents.SetOllamaModel(modelName)) }, [props.plugin, selectedModel]) // refresh context whenever selection changes (even if selector is closed) @@ -636,7 +637,7 @@ export const RemixUiRemixAiAssistant = React.forwardRef< if (description && description.trim()) { sendPrompt(`/generate ${description.trim()}`) - _paq.push(['trackEvent', 'remixAI', 'GenerateNewAIWorkspaceFromModal', description]) + trackMatomoEvent?.(RemixAIEvents.GenerateNewAIWorkspaceFromModal(description)) } } catch { /* user cancelled */ diff --git a/libs/remix-ui/renderer/src/lib/renderer.tsx b/libs/remix-ui/renderer/src/lib/renderer.tsx index 9938abf94c9..33bc0f2480e 100644 --- a/libs/remix-ui/renderer/src/lib/renderer.tsx +++ b/libs/remix-ui/renderer/src/lib/renderer.tsx @@ -1,9 +1,10 @@ -import React, {useEffect, useState} from 'react' //eslint-disable-line +import React, {useContext, useEffect, useState} from 'react' //eslint-disable-line import { useIntl } from 'react-intl' import { CopyToClipboard } from '@remix-ui/clipboard' import { helper } from '@remix-project/remix-solidity' +import { TrackingContext } from '@remix-ide/tracking' +import { AIEvents } from '@remix-api' import './renderer.css' -const _paq = (window._paq = window._paq || []) interface RendererProps { message: any @@ -23,6 +24,7 @@ type RendererOptions = { export const Renderer = ({ message, opt, plugin, context }: RendererProps) => { const intl = useIntl() + const { trackMatomoEvent } = useContext(TrackingContext) const [messageText, setMessageText] = useState(null) const [editorOptions, setEditorOptions] = useState({ useSpan: false, @@ -100,7 +102,7 @@ export const Renderer = ({ message, opt, plugin, context }: RendererProps) => { setTimeout(async () => { await plugin.call('remixAI' as any, 'chatPipe', 'error_explaining', message) }, 500) - _paq.push(['trackEvent', 'ai', 'remixAI', 'error_explaining_SolidityError']) + trackMatomoEvent?.(AIEvents.remixAI('error_explaining_SolidityError')) } catch (err) { console.error('unable to ask RemixAI') console.error(err) diff --git a/libs/remix-ui/run-tab/src/lib/actions/account.ts b/libs/remix-ui/run-tab/src/lib/actions/account.ts index ce8ccbd8ec6..efa58a57edf 100644 --- a/libs/remix-ui/run-tab/src/lib/actions/account.ts +++ b/libs/remix-ui/run-tab/src/lib/actions/account.ts @@ -1,5 +1,6 @@ import { shortenAddress } from "@remix-ui/helper" import { RunTab } from "../types/run-tab" +import { trackMatomoEvent, UdappEvents } from '@remix-api' import { clearInstances, setAccount, setExecEnv } from "./actions" import { displayNotification, fetchAccountsListFailed, fetchAccountsListRequest, fetchAccountsListSuccess, setMatchPassphrase, setPassphrase } from "./payload" import { toChecksumAddress, bytesToHex, isZeroAddress } from '@ethereumjs/util' @@ -13,7 +14,6 @@ import { entryPoint07Address } from "viem/account-abstraction" const { createSmartAccountClient } = require("permissionless") /* eslint-disable-line @typescript-eslint/no-var-requires */ const { toSafeSmartAccount } = require("permissionless/accounts") /* eslint-disable-line @typescript-eslint/no-var-requires */ const { createPimlicoClient } = require("permissionless/clients/pimlico") /* eslint-disable-line @typescript-eslint/no-var-requires */ -const _paq = window._paq = window._paq || [] export const updateAccountBalances = async (plugin: RunTab, dispatch: React.Dispatch) => { const accounts = plugin.REACT_API.accounts.loadedAccounts @@ -274,10 +274,10 @@ export const createSmartAccount = async (plugin: RunTab, dispatch: React.Dispatc smartAccountsObj[chainId] = plugin.REACT_API.smartAccounts localStorage.setItem(aaLocalStorageKey, JSON.stringify(smartAccountsObj)) await fillAccountsList(plugin, dispatch) - _paq.push(['trackEvent', 'udapp', 'safeSmartAccount', `createdSuccessfullyForChainID:${chainId}`]) + await trackMatomoEvent(plugin, UdappEvents.safeSmartAccount(`createdSuccessfullyForChainID:${chainId}`)) return plugin.call('notification', 'toast', `Safe account ${safeAccount.address} created for owner ${account}`) } catch (error) { - _paq.push(['trackEvent', 'udapp', 'safeSmartAccount', `creationFailedWithError:${error.message}`]) + await trackMatomoEvent(plugin, UdappEvents.safeSmartAccount(`creationFailedWithError:${error.message}`)) console.error('Failed to create safe smart account: ', error) if (error.message.includes('User rejected the request')) return plugin.call('notification', 'toast', `User rejected the request to create safe smart account !!!`) else return plugin.call('notification', 'toast', `Failed to create safe smart account !!!`) diff --git a/libs/remix-ui/run-tab/src/lib/actions/deploy.ts b/libs/remix-ui/run-tab/src/lib/actions/deploy.ts index f587450c9ed..03ac852ed90 100644 --- a/libs/remix-ui/run-tab/src/lib/actions/deploy.ts +++ b/libs/remix-ui/run-tab/src/lib/actions/deploy.ts @@ -1,4 +1,5 @@ import { ContractData, FuncABI, NetworkDeploymentFile, SolcBuildFile, OverSizeLimit } from "@remix-project/core-plugin" +import { trackMatomoEvent, UdappEvents } from '@remix-api' import { RunTab } from "../types/run-tab" import { CompilerAbstract as CompilerAbstractType } from '@remix-project/remix-solidity' import * as remixLib from '@remix-project/remix-lib' @@ -11,13 +12,6 @@ import { addInstance } from "./actions" import { addressToString, logBuilder } from "@remix-ui/helper" import { Web3 } from "web3" -declare global { - interface Window { - _paq: any - } -} - -const _paq = window._paq = window._paq || [] //eslint-disable-line const txHelper = remixLib.execution.txHelper const txFormat = remixLib.execution.txFormat @@ -31,11 +25,11 @@ const loadContractFromAddress = (plugin: RunTab, address, confirmCb, cb) => { } catch (e) { return cb('Failed to parse the current file as JSON ABI.') } - _paq.push(['trackEvent', 'udapp', 'useAtAddress' , 'AtAddressLoadWithABI']) + trackMatomoEvent(plugin, UdappEvents.useAtAddress('AtAddressLoadWithABI')) cb(null, 'abi', abi) }) } else { - _paq.push(['trackEvent', 'udapp', 'useAtAddress', 'AtAddressLoadWithArtifacts']) + trackMatomoEvent(plugin, UdappEvents.useAtAddress('AtAddressLoadWithArtifacts')) cb(null, 'instance') } } @@ -181,10 +175,10 @@ export const createInstance = async ( plugin.compilersArtefacts.addResolvedContract(addressToString(address), data) if (plugin.REACT_API.ipfsChecked) { - _paq.push(['trackEvent', 'udapp', 'DeployAndPublish', plugin.REACT_API.networkName]) + trackMatomoEvent(plugin, UdappEvents.DeployAndPublish(plugin.REACT_API.networkName)) publishToStorage('ipfs', selectedContract) } else { - _paq.push(['trackEvent', 'udapp', 'DeployOnly', plugin.REACT_API.networkName]) + trackMatomoEvent(plugin, UdappEvents.DeployOnly(plugin.REACT_API.networkName)) } if (isProxyDeployment) { const initABI = contractObject.abi.find(abi => abi.name === 'initialize') @@ -243,7 +237,7 @@ export const createInstance = async ( } const deployContract = (plugin: RunTab, selectedContract, args, contractMetadata, compilerContracts, callbacks, confirmationCb) => { - _paq.push(['trackEvent', 'udapp', 'DeployContractTo', plugin.REACT_API.networkName]) + trackMatomoEvent(plugin, UdappEvents.deployContractTo(plugin.REACT_API.networkName)) const { statusCb } = callbacks if (!contractMetadata || (contractMetadata && contractMetadata.autoDeployLib)) { @@ -308,10 +302,18 @@ export const runTransactions = ( passphrasePrompt: (msg: string) => JSX.Element, funcIndex?: number) => { let callinfo = '' - if (lookupOnly) callinfo = 'call' - else if (funcABI.type === 'fallback' || funcABI.type === 'receive') callinfo = 'lowLevelinteractions' - else callinfo = 'transact' - _paq.push(['trackEvent', 'udapp', callinfo, plugin.REACT_API.networkName]) + let eventMethod + if (lookupOnly) { + callinfo = 'call' + eventMethod = UdappEvents.call + } else if (funcABI.type === 'fallback' || funcABI.type === 'receive') { + callinfo = 'lowLevelinteractions' + eventMethod = UdappEvents.lowLevelinteractions + } else { + callinfo = 'transact' + eventMethod = UdappEvents.transact + } + trackMatomoEvent(plugin, eventMethod(plugin.REACT_API.networkName)) const params = funcABI.type !== 'fallback' ? inputsValues : '' plugin.blockchain.runOrCallContractMethod( diff --git a/libs/remix-ui/run-tab/src/lib/actions/events.ts b/libs/remix-ui/run-tab/src/lib/actions/events.ts index de198d5cb4f..665703dcc7a 100644 --- a/libs/remix-ui/run-tab/src/lib/actions/events.ts +++ b/libs/remix-ui/run-tab/src/lib/actions/events.ts @@ -1,5 +1,6 @@ import { envChangeNotification } from "@remix-ui/helper" import { RunTab } from "../types/run-tab" +import { trackMatomoEvent, UdappEvents } from '@remix-api' import { setExecutionContext, setFinalContext, updateAccountBalances, fillAccountsList } from "./account" import { addExternalProvider, addInstance, addNewProxyDeployment, removeExternalProvider, setNetworkNameFromProvider, setPinnedChainId, setExecEnv } from "./actions" import { addDeployOption, clearAllInstances, clearRecorderCount, fetchContractListSuccess, resetProxyDeployments, resetUdapp, setCurrentContract, setCurrentFile, setLoadType, setRecorderCount, setRemixDActivated, setSendValue, fetchAccountsListSuccess, fetchAccountsListRequest } from "./payload" @@ -11,7 +12,6 @@ import { Plugin } from "@remixproject/engine" import { getNetworkProxyAddresses } from "./deploy" import { shortenAddress } from "@remix-ui/helper" -const _paq = window._paq = window._paq || [] let dispatch: React.Dispatch = () => {} export const setEventsDispatch = (reducerDispatch: React.Dispatch) => { @@ -238,7 +238,7 @@ const migrateSavedContracts = async (plugin) => { } const broadcastCompilationResult = async (compilerName: string, plugin: RunTab, dispatch: React.Dispatch, file, source, languageVersion, data, input?) => { - _paq.push(['trackEvent', 'udapp', 'broadcastCompilationResult', compilerName]) + await trackMatomoEvent(plugin, UdappEvents.broadcastCompilationResult(compilerName)) // TODO check whether the tab is configured const compiler = new CompilerAbstract(languageVersion, data, source, input) plugin.compilersArtefacts[languageVersion] = compiler diff --git a/libs/remix-ui/run-tab/src/lib/actions/index.ts b/libs/remix-ui/run-tab/src/lib/actions/index.ts index 49e2bc6ff18..8738cab7f70 100644 --- a/libs/remix-ui/run-tab/src/lib/actions/index.ts +++ b/libs/remix-ui/run-tab/src/lib/actions/index.ts @@ -13,13 +13,6 @@ import { DeployMode, MainnetPrompt } from '../types' import { runCurrentScenario, storeScenario } from './recorder' import { SolcInput, SolcOutput } from '@openzeppelin/upgrades-core' -declare global { - interface Window { - _paq: any - } -} - -const _paq = window._paq = window._paq || [] //eslint-disable-line let plugin: RunTab, dispatch: React.Dispatch = () => {} export const initRunTab = (udapp: RunTab, resetEventsAndAccounts: boolean) => async (reducerDispatch: React.Dispatch) => { diff --git a/libs/remix-ui/run-tab/src/lib/components/account.tsx b/libs/remix-ui/run-tab/src/lib/components/account.tsx index c2e14bca7c1..d68a55ee091 100644 --- a/libs/remix-ui/run-tab/src/lib/components/account.tsx +++ b/libs/remix-ui/run-tab/src/lib/components/account.tsx @@ -1,5 +1,5 @@ // eslint-disable-next-line no-use-before-define -import React, { useEffect, useState, useRef } from 'react' +import React, { useEffect, useState, useRef, useContext } from 'react' import { FormattedMessage, useIntl } from 'react-intl' import { CopyToClipboard } from '@remix-ui/clipboard' import { AccountProps } from '../types' @@ -7,12 +7,14 @@ import { PassphrasePrompt } from './passphrase' import { shortenAddress, CustomMenu, CustomToggle, CustomTooltip } from '@remix-ui/helper' import { eip7702Constants } from '@remix-project/remix-lib' import { Dropdown } from 'react-bootstrap' -const _paq = window._paq = window._paq || [] +import { TrackingContext } from '@remix-ide/tracking' +import { UdappEvents } from '@remix-api' export function AccountUI(props: AccountProps) { const { selectedAccount, loadedAccounts } = props.accounts const { selectExEnv, personalMode, networkName } = props const accounts = Object.keys(loadedAccounts) + const { trackMatomoEvent } = useContext(TrackingContext) const [plusOpt, setPlusOpt] = useState({ classList: '', title: '' @@ -189,7 +191,7 @@ export function AccountUI(props: AccountProps) { href="https://docs.safe.global/advanced/smart-account-overview#safe-smart-account" target="_blank" rel="noreferrer noopener" - onClick={() => _paq.push(['trackEvent', 'udapp', 'safeSmartAccount', 'learnMore'])} + onClick={() => trackMatomoEvent?.(UdappEvents.safeSmartAccount('learnMore'))} className="mb-3 d-inline-block link-primary" > Learn more @@ -227,12 +229,12 @@ export function AccountUI(props: AccountProps) { ), intl.formatMessage({ id: 'udapp.continue' }), () => { - _paq.push(['trackEvent', 'udapp', 'safeSmartAccount', 'createClicked']) + trackMatomoEvent?.(UdappEvents.safeSmartAccount('createClicked')) props.createNewSmartAccount() }, intl.formatMessage({ id: 'udapp.cancel' }), () => { - _paq.push(['trackEvent', 'udapp', 'safeSmartAccount', 'cancelClicked']) + trackMatomoEvent?.(UdappEvents.safeSmartAccount('cancelClicked')) } ) } @@ -262,7 +264,7 @@ export function AccountUI(props: AccountProps) { try { await props.delegationAuthorization(delegationAuthorizationAddressRef.current) setContractHasDelegation(true) - _paq.push(['trackEvent', 'udapp', 'contractDelegation', 'create']) + trackMatomoEvent?.(UdappEvents.contractDelegation('create')) } catch (e) { props.runTabPlugin.call('terminal', 'log', { type: 'error', value: e.message }) } @@ -288,7 +290,7 @@ export function AccountUI(props: AccountProps) { await props.delegationAuthorization('0x0000000000000000000000000000000000000000') delegationAuthorizationAddressRef.current = '' setContractHasDelegation(false) - _paq.push(['trackEvent', 'udapp', 'contractDelegation', 'remove']) + trackMatomoEvent?.(UdappEvents.contractDelegation('remove')) } catch (e) { props.runTabPlugin.call('terminal', 'log', { type: 'error', value: e.message }) } @@ -303,7 +305,7 @@ export function AccountUI(props: AccountProps) { } const signMessage = () => { - _paq.push(['trackEvent', 'udapp', 'signUsingAccount', `selectExEnv: ${selectExEnv}`]) + trackMatomoEvent?.(UdappEvents.signUsingAccount(`selectExEnv: ${selectExEnv}`)) if (!accounts[0]) { return props.tooltip(intl.formatMessage({ id: 'udapp.tooltipText1' })) } diff --git a/libs/remix-ui/run-tab/src/lib/components/contractDropdownUI.tsx b/libs/remix-ui/run-tab/src/lib/components/contractDropdownUI.tsx index 6df5b4a8dfc..37cbbd850e3 100644 --- a/libs/remix-ui/run-tab/src/lib/components/contractDropdownUI.tsx +++ b/libs/remix-ui/run-tab/src/lib/components/contractDropdownUI.tsx @@ -1,15 +1,17 @@ // eslint-disable-next-line no-use-before-define -import React, { useEffect, useRef, useState } from 'react' +import React, { useContext, useEffect, useRef, useState } from 'react' import { FormattedMessage, useIntl } from 'react-intl' import { ContractDropdownProps, DeployMode } from '../types' import { ContractData, FuncABI, OverSizeLimit } from '@remix-project/core-plugin' import * as ethJSUtil from '@ethereumjs/util' import { ContractGUI } from './contractGUI' import { CustomTooltip, deployWithProxyMsg, upgradeWithProxyMsg } from '@remix-ui/helper' -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { UdappEvents } from '@remix-api' export function ContractDropdownUI(props: ContractDropdownProps) { const intl = useIntl() + const { trackMatomoEvent } = useContext(TrackingContext) const [abiLabel, setAbiLabel] = useState<{ display: string content: string @@ -404,7 +406,7 @@ export function ContractDropdownUI(props: ContractDropdownProps) { > { props.syncContracts() - _paq.push(['trackEvent', 'udapp', 'syncContracts', compilationSource ? compilationSource : 'compilationSourceNotYetSet']) + trackMatomoEvent?.(UdappEvents.syncContracts(compilationSource ? compilationSource : 'compilationSourceNotYetSet')) }} className="udapp_syncFramework udapp_icon fa fa-refresh" aria-hidden="true"> ) : null} diff --git a/libs/remix-ui/run-tab/src/lib/components/environment.tsx b/libs/remix-ui/run-tab/src/lib/components/environment.tsx index 24c5da3ca9a..0e6009b62cc 100644 --- a/libs/remix-ui/run-tab/src/lib/components/environment.tsx +++ b/libs/remix-ui/run-tab/src/lib/components/environment.tsx @@ -1,15 +1,16 @@ // eslint-disable-next-line no-use-before-define -import React, { useRef, useState, useEffect } from 'react' +import React, { useRef, useState, useEffect, useContext } from 'react' import { FormattedMessage, useIntl } from 'react-intl' import { EnvironmentProps } from '../types' import { Dropdown } from 'react-bootstrap' import { CustomMenu, CustomToggle, CustomTooltip } from '@remix-ui/helper' import { DropdownLabel } from './dropdownLabel' import SubmenuPortal from './subMenuPortal' - -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { UdappEvents } from '@remix-api' export function EnvironmentUI(props: EnvironmentProps) { + const { trackMatomoEvent } = useContext(TrackingContext) const vmStateName = useRef('') const providers = props.providers.providerList const [isSwitching, setIsSwitching] = useState(false) @@ -104,7 +105,7 @@ export function EnvironmentUI(props: EnvironmentProps) { } const forkState = async () => { - _paq.push(['trackEvent', 'udapp', 'forkState', `forkState clicked`]) + trackMatomoEvent?.(UdappEvents.forkState(`forkState clicked`)) let context = currentProvider.name context = context.replace('vm-fs-', '') @@ -141,7 +142,7 @@ export function EnvironmentUI(props: EnvironmentProps) { await props.runTabPlugin.call('fileManager', 'copyDir', `.deploys/pinned-contracts/${currentProvider.name}`, `.deploys/pinned-contracts`, 'vm-fs-' + vmStateName.current) } } - _paq.push(['trackEvent', 'udapp', 'forkState', `forked from ${context}`]) + trackMatomoEvent?.(UdappEvents.forkState(`forked from ${context}`)) }, intl.formatMessage({ id: 'udapp.cancel' }), () => {} @@ -149,7 +150,7 @@ export function EnvironmentUI(props: EnvironmentProps) { } const resetVmState = async() => { - _paq.push(['trackEvent', 'udapp', 'deleteState', `deleteState clicked`]) + trackMatomoEvent?.(UdappEvents.deleteState(`deleteState clicked`)) const context = currentProvider.name const contextExists = await props.runTabPlugin.call('fileManager', 'exists', `.states/${context}/state.json`) if (contextExists) { @@ -169,7 +170,7 @@ export function EnvironmentUI(props: EnvironmentProps) { const isPinnedContracts = await props.runTabPlugin.call('fileManager', 'exists', `.deploys/pinned-contracts/${context}`) if (isPinnedContracts) await props.runTabPlugin.call('fileManager', 'remove', `.deploys/pinned-contracts/${context}`) props.runTabPlugin.call('notification', 'toast', `VM state reset successfully.`) - _paq.push(['trackEvent', 'udapp', 'deleteState', `VM state reset`]) + trackMatomoEvent?.(UdappEvents.deleteState(`VM state reset`)) }, intl.formatMessage({ id: 'udapp.cancel' }), null diff --git a/libs/remix-ui/run-tab/src/lib/components/universalDappUI.tsx b/libs/remix-ui/run-tab/src/lib/components/universalDappUI.tsx index f370836d825..99f0c4c96ee 100644 --- a/libs/remix-ui/run-tab/src/lib/components/universalDappUI.tsx +++ b/libs/remix-ui/run-tab/src/lib/components/universalDappUI.tsx @@ -1,5 +1,5 @@ // eslint-disable-next-line no-use-before-define -import React, { useEffect, useState } from 'react' +import React, { useContext, useEffect, useState } from 'react' import { FormattedMessage, useIntl } from 'react-intl' import { UdappProps } from '../types' import { FuncABI } from '@remix-project/core-plugin' @@ -10,12 +10,14 @@ import { ContractGUI } from './contractGUI' import { TreeView, TreeViewItem } from '@remix-ui/tree-view' import { BN } from 'bn.js' import { CustomTooltip, is0XPrefixed, isHexadecimal, isNumeric, shortenAddress } from '@remix-ui/helper' -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { UdappEvents } from '@remix-api' const txHelper = remixLib.execution.txHelper export function UniversalDappUI(props: UdappProps) { const intl = useIntl() + const { trackMatomoEvent } = useContext(TrackingContext) const [toggleExpander, setToggleExpander] = useState(true) const [contractABI, setContractABI] = useState(null) const [address, setAddress] = useState('') @@ -117,14 +119,14 @@ export function UniversalDappUI(props: UdappProps) { const remove = async() => { if (props.instance.isPinned) { await unsavePinnedContract() - _paq.push(['trackEvent', 'udapp', 'pinContracts', 'removePinned']) + trackMatomoEvent?.(UdappEvents.pinContracts('removePinned')) } props.removeInstance(props.index) } const unpinContract = async() => { await unsavePinnedContract() - _paq.push(['trackEvent', 'udapp', 'pinContracts', 'unpinned']) + trackMatomoEvent?.(UdappEvents.pinContracts('unpinned')) props.unpinInstance(props.index) } @@ -146,12 +148,12 @@ export function UniversalDappUI(props: UdappProps) { pinnedAt: Date.now() } await props.plugin.call('fileManager', 'writeFile', `.deploys/pinned-contracts/${props.plugin.REACT_API.chainId}/${props.instance.address}.json`, JSON.stringify(objToSave, null, 2)) - _paq.push(['trackEvent', 'udapp', 'pinContracts', `pinned at ${props.plugin.REACT_API.chainId}`]) + trackMatomoEvent?.(UdappEvents.pinContracts(`pinned at ${props.plugin.REACT_API.chainId}`)) props.pinInstance(props.index, objToSave.pinnedAt, objToSave.filePath) } const runTransaction = (lookupOnly, funcABI: FuncABI, valArr, inputsValues, funcIndex?: number) => { - if (props.instance.isPinned) _paq.push(['trackEvent', 'udapp', 'pinContracts', 'interactWithPinned']) + if (props.instance.isPinned) trackMatomoEvent?.(UdappEvents.pinContracts('interactWithPinned')) const functionName = funcABI.type === 'function' ? funcABI.name : `(${funcABI.type})` const logMsg = `${lookupOnly ? 'call' : 'transact'} to ${props.instance.name}.${functionName}` diff --git a/libs/remix-ui/scriptrunner/src/lib/components/config-section.tsx b/libs/remix-ui/scriptrunner/src/lib/components/config-section.tsx index 05b6afb053c..41998b82f04 100644 --- a/libs/remix-ui/scriptrunner/src/lib/components/config-section.tsx +++ b/libs/remix-ui/scriptrunner/src/lib/components/config-section.tsx @@ -1,12 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useContext, useEffect, useRef, useState } from 'react' import { FormattedMessage } from 'react-intl' import { ProjectConfiguration } from '../../types'; import { faCheck, faTimes, faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { CustomTooltip } from '@remix-ui/helper'; +import { TrackingContext } from '@remix-ide/tracking'; +import { ScriptRunnerPluginEvents } from '@remix-api'; export interface ConfigSectionProps { - _paq: any; activeKey: string setActiveKey: (key: string) => void config: ProjectConfiguration @@ -17,6 +18,7 @@ export interface ConfigSectionProps { export default function ConfigSection(props: ConfigSectionProps) { const [isVisible, setIsVisible] = useState(true) + const { trackMatomoEvent } = useContext(TrackingContext) const handleAnimationEnd = () => { setIsVisible(false); @@ -37,7 +39,7 @@ export default function ConfigSection(props: ConfigSectionProps) { if (!props.config.errorStatus) { props.setActiveKey(props.config.name) } - props._paq.push(['trackEvent', 'scriptRunnerPlugin', 'loadScriptRunnerConfig', props.config.name]) + trackMatomoEvent?.(ScriptRunnerPluginEvents.loadScriptRunnerConfig(props.config.name)) }} checked={(props.activeConfig && props.activeConfig.name === props.config.name)} /> @@ -108,7 +110,7 @@ export default function ConfigSection(props: ConfigSectionProps) {
{ props.loadScriptRunner(props.config) - props._paq.push(['trackEvent', 'scriptRunnerPlugin', 'error_reloadScriptRunnerConfig', props.config.name]) + trackMatomoEvent?.(ScriptRunnerPluginEvents.error_reloadScriptRunnerConfig(props.config.name)) }} className="pointer text-danger d-flex flex-row" > diff --git a/libs/remix-ui/scriptrunner/src/lib/script-runner-ui.tsx b/libs/remix-ui/scriptrunner/src/lib/script-runner-ui.tsx index c47ffcbbf5f..14da5820d80 100644 --- a/libs/remix-ui/scriptrunner/src/lib/script-runner-ui.tsx +++ b/libs/remix-ui/scriptrunner/src/lib/script-runner-ui.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useState } from "react"; import { customScriptRunnerConfig, ProjectConfiguration } from "../types"; import { CustomScriptRunner } from "./custom-script-runner"; import ConfigSection from "./components/config-section"; -const _paq = (window._paq = window._paq || []) // eslint-disable-line export interface ScriptRunnerUIProps { loadScriptRunner: (config: ProjectConfiguration) => void; @@ -43,7 +42,6 @@ export const ScriptRunnerUI = (props: ScriptRunnerUIProps) => { config={config} key={index} loadScriptRunner={loadScriptRunner} - _paq={_paq} activeConfig={activeConfig} /> ))} diff --git a/libs/remix-ui/settings/src/lib/remix-ui-settings.tsx b/libs/remix-ui/settings/src/lib/remix-ui-settings.tsx index 294664289a4..15a9ea311aa 100644 --- a/libs/remix-ui/settings/src/lib/remix-ui-settings.tsx +++ b/libs/remix-ui/settings/src/lib/remix-ui-settings.tsx @@ -24,7 +24,6 @@ export interface RemixUiSettingsProps { themeModule: ThemeModule } -const _paq = (window._paq = window._paq || []) const settingsConfig = Registry.getInstance().get('settingsConfig').api const settingsSections: SettingsSection[] = [ diff --git a/libs/remix-ui/settings/src/lib/settingsReducer.ts b/libs/remix-ui/settings/src/lib/settingsReducer.ts index 35907d6d19a..7c650d0e530 100644 --- a/libs/remix-ui/settings/src/lib/settingsReducer.ts +++ b/libs/remix-ui/settings/src/lib/settingsReducer.ts @@ -87,7 +87,7 @@ export const initialState: SettingsState = { isLoading: false }, 'matomo-analytics': { - value: config.get('settings/matomo-analytics') || true, + value: true, // Deprecated --- IGNORE --- isLoading: false }, 'auto-completion': { diff --git a/libs/remix-ui/solidity-compile-details/src/lib/components/solidityCompile.tsx b/libs/remix-ui/solidity-compile-details/src/lib/components/solidityCompile.tsx index 3145aeea8d6..e89522e5a58 100644 --- a/libs/remix-ui/solidity-compile-details/src/lib/components/solidityCompile.tsx +++ b/libs/remix-ui/solidity-compile-details/src/lib/components/solidityCompile.tsx @@ -1,15 +1,17 @@ import { CopyToClipboard } from '@remix-ui/clipboard' import { CustomTooltip } from '@remix-ui/helper' import { ContractPropertyName } from '@remix-ui/solidity-compiler' -import React from 'react' +import React, { useContext } from 'react' import { TreeView, TreeViewItem } from '@remix-ui/tree-view' import { useIntl } from 'react-intl' -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { CompilerEvents } from '@remix-api' export default function SolidityCompile({ contractProperties, selectedContract, help, insertValue, saveAs, plugin }: any) { const intl = useIntl() + const { trackMatomoEvent } = useContext(TrackingContext) const downloadFn = () => { - _paq.push(['trackEvent', 'compiler', 'compilerDetails', 'download']) + trackMatomoEvent?.(CompilerEvents.compilerDetails('download')) saveAs(new Blob([JSON.stringify(contractProperties, null, '\t')]), `${selectedContract}_compData.json`) } return ( diff --git a/libs/remix-ui/solidity-compile-details/src/lib/solidity-compile-details.tsx b/libs/remix-ui/solidity-compile-details/src/lib/solidity-compile-details.tsx index f187c37fac8..ec8f9b8760f 100644 --- a/libs/remix-ui/solidity-compile-details/src/lib/solidity-compile-details.tsx +++ b/libs/remix-ui/solidity-compile-details/src/lib/solidity-compile-details.tsx @@ -15,8 +15,6 @@ export interface RemixUiCompileDetailsProps { saveAs: any } -const _paq = (window._paq = window._paq || []) - export function RemixUiCompileDetails({ plugin, contractProperties, selectedContract, saveAs, help, insertValue }: RemixUiCompileDetailsProps) { return ( diff --git a/libs/remix-ui/solidity-compiler/src/lib/compiler-container.tsx b/libs/remix-ui/solidity-compiler/src/lib/compiler-container.tsx index 7f779bf7ae4..80979b27309 100644 --- a/libs/remix-ui/solidity-compiler/src/lib/compiler-container.tsx +++ b/libs/remix-ui/solidity-compiler/src/lib/compiler-container.tsx @@ -11,24 +11,19 @@ import { getValidLanguage } from '@remix-project/remix-solidity' import { CopyToClipboard } from '@remix-ui/clipboard' import { configFileContent } from './compilerConfiguration' import { appPlatformTypes, platformContext, onLineContext } from '@remix-ui/app' +import { TrackingContext } from '@remix-ide/tracking' +import { CompilerEvents, CompilerContainerEvents } from '@remix-api' import * as packageJson from '../../../../../package.json' import './css/style.css' import { CompilerDropdown } from './components/compiler-dropdown' - -declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _paq: any - } -} -const _paq = (window._paq = window._paq || []) //eslint-disable-line const remixConfigPath = 'remix.config.json' export const CompilerContainer = (props: CompilerContainerProps) => { const online = useContext(onLineContext) const platform = useContext(platformContext) + const { trackMatomoEvent } = useContext(TrackingContext) const { api, compileTabLogic, @@ -207,6 +202,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const toggleConfigType = () => { setState((prevState) => { + // Track configuration file toggle + trackMatomoEvent?.(CompilerContainerEvents.useConfigurationFile(!state.useFileConfiguration ? 'enabled' : 'disabled')) + api.setAppParameter('useFileConfiguration', !state.useFileConfiguration) return { ...prevState, useFileConfiguration: !state.useFileConfiguration } }) @@ -404,9 +402,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { compileIcon.current.classList.remove('remixui_spinningIcon') compileIcon.current.classList.remove('remixui_bouncingIcon') if (!state.autoCompile || (state.autoCompile && state.matomoAutocompileOnce)) { - // _paq.push(['trackEvent', 'compiler', 'compiled', 'solCompilationFinishedTriggeredByUser']) - _paq.push(['trackEvent', 'compiler', 'compiled', 'with_config_file_' + state.useFileConfiguration]) - _paq.push(['trackEvent', 'compiler', 'compiled', 'with_version_' + _retrieveVersion()]) + // trackMatomoEvent?.('compiler', 'compiled', 'solCompilationFinishedTriggeredByUser') + trackMatomoEvent?.(CompilerEvents.compiled('with_config_file_' + state.useFileConfiguration)) + trackMatomoEvent?.(CompilerEvents.compiled('with_version_' + _retrieveVersion())) if (state.autoCompile && state.matomoAutocompileOnce) { setState((prevState) => { return { ...prevState, matomoAutocompileOnce: false } @@ -431,6 +429,10 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const currentFile = api.currentFile if (!isSolFileSelected()) return + + // Track compile button click + trackMatomoEvent?.(CompilerContainerEvents.compile(currentFile)) + if (state.useFileConfiguration) await createNewConfigFile() _setCompilerVersionFromPragma(currentFile) let externalCompType @@ -443,6 +445,10 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const currentFile = api.currentFile if (!isSolFileSelected()) return + + // Track compile and run button click + trackMatomoEvent?.(CompilerContainerEvents.compileAndRun(currentFile)) + _setCompilerVersionFromPragma(currentFile) let externalCompType if (hhCompilation) externalCompType = 'hardhat' @@ -505,6 +511,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { } const promptCompiler = () => { + // Track custom compiler addition prompt + trackMatomoEvent?.(CompilerContainerEvents.addCustomCompiler()) + // custom url https://solidity-blog.s3.eu-central-1.amazonaws.com/data/08preview/soljson.js modal( intl.formatMessage({ @@ -520,6 +529,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { } const showCompilerLicense = () => { + // Track compiler license view + trackMatomoEvent?.(CompilerContainerEvents.viewLicense()) + modal( intl.formatMessage({ id: 'solidity.compilerLicense' }), state.compilerLicense ? state.compilerLicense : intl.formatMessage({ id: 'solidity.compilerLicenseMsg3' }), @@ -548,6 +560,10 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const handleLoadVersion = (value) => { if (value !== 'builtin' && !pathToURL[value]) return + + // Track compiler selection + trackMatomoEvent?.(CompilerContainerEvents.compilerSelection(value)) + setState((prevState) => { return { ...prevState, selectedVersion: value, matomoAutocompileOnce: true } }) @@ -567,6 +583,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const handleAutoCompile = (e) => { const checked = e.target.checked + // Track auto-compile toggle + trackMatomoEvent?.(CompilerContainerEvents.autoCompile(checked ? 'enabled' : 'disabled')) + api.setAppParameter('autoCompile', checked) checked && compile() setState((prevState) => { @@ -581,6 +600,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const handleOptimizeChange = (value) => { const checked = !!value + // Track optimization toggle + trackMatomoEvent?.(CompilerContainerEvents.optimization(checked ? 'enabled' : 'disabled')) + api.setAppParameter('optimize', checked) compileTabLogic.setOptimize(checked) if (compileTabLogic.optimize) { @@ -607,6 +629,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const handleHideWarningsChange = (e) => { const checked = e.target.checked + // Track hide warnings toggle + trackMatomoEvent?.(CompilerContainerEvents.hideWarnings(checked ? 'enabled' : 'disabled')) + api.setAppParameter('hideWarnings', checked) state.autoCompile && compile() setState((prevState) => { @@ -617,6 +642,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const handleNightliesChange = (e) => { const checked = e.target.checked + // Track include nightlies toggle + trackMatomoEvent?.(CompilerContainerEvents.includeNightlies(checked ? 'enabled' : 'disabled')) + if (!checked) handleLoadVersion(state.defaultVersion) api.setAppParameter('includeNightlies', checked) setState((prevState) => { @@ -626,6 +654,10 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const handleOnlyDownloadedChange = (e) => { const checked = e.target.checked + + // Track downloaded compilers only toggle - we can use compilerSelection for this + trackMatomoEvent?.(CompilerContainerEvents.compilerSelection(checked ? 'downloadedOnly' : 'allVersions')) + if (!checked) handleLoadVersion(state.defaultVersion) setState((prevState) => { return { ...prevState, onlyDownloaded: checked } @@ -633,6 +665,9 @@ export const CompilerContainer = (props: CompilerContainerProps) => { } const handleLanguageChange = (value) => { + // Track language selection + trackMatomoEvent?.(CompilerContainerEvents.languageSelection(value)) + compileTabLogic.setLanguage(value) state.autoCompile && compile() setState((prevState) => { @@ -642,6 +677,10 @@ export const CompilerContainer = (props: CompilerContainerProps) => { const handleEvmVersionChange = (value) => { if (!value) return + + // Track EVM version selection + trackMatomoEvent?.(CompilerContainerEvents.evmVersionSelection(value)) + let v = value if (v === 'default') { v = null @@ -823,14 +862,22 @@ export const CompilerContainer = (props: CompilerContainerProps) => {
)} -
+
{ + // Track advanced configuration toggle + trackMatomoEvent?.(CompilerContainerEvents.advancedConfigToggle(!toggleExpander ? 'expanded' : 'collapsed')) + toggleConfigurations() + }}>
- + { + // Track advanced configuration toggle + trackMatomoEvent?.(CompilerContainerEvents.advancedConfigToggle(!toggleExpander ? 'expanded' : 'collapsed')) + toggleConfigurations() + }}>
diff --git a/libs/remix-ui/solidity-compiler/src/lib/contract-selection.tsx b/libs/remix-ui/solidity-compiler/src/lib/contract-selection.tsx index 869c180da61..02193919f93 100644 --- a/libs/remix-ui/solidity-compiler/src/lib/contract-selection.tsx +++ b/libs/remix-ui/solidity-compiler/src/lib/contract-selection.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect} from 'react' // eslint-disable-line +import React, {useState, useEffect, useContext} from 'react' // eslint-disable-line import { FormattedMessage, useIntl } from 'react-intl' import { ContractPropertyName, ContractSelectionProps } from './types' import {PublishToStorage} from '@remix-ui/publish-to-storage' // eslint-disable-line @@ -6,16 +6,17 @@ import {TreeView, TreeViewItem} from '@remix-ui/tree-view' // eslint-disable-lin import {CopyToClipboard} from '@remix-ui/clipboard' // eslint-disable-line import { saveAs } from 'file-saver' import { AppModal } from '@remix-ui/app' +import { TrackingContext } from '@remix-ide/tracking' +import { CompilerEvents, SolidityCompilerEvents } from '@remix-api' import './css/style.css' import { CustomTooltip, handleSolidityScan } from '@remix-ui/helper' -const _paq = (window._paq = window._paq || []) - export const ContractSelection = (props: ContractSelectionProps) => { const { api, compiledFileName, contractsDetails, contractList, compilerInput, modal } = props const [selectedContract, setSelectedContract] = useState('') const [storage, setStorage] = useState(null) + const { trackMatomoEvent } = useContext(TrackingContext) const intl = useIntl() @@ -61,9 +62,8 @@ export const ContractSelection = (props: ContractSelectionProps) => { } const getContractProperty = (property) => { - if (!selectedContract) throw new Error('No contract compiled yet') + if (!selectedContract) return const contractProperties = contractsDetails[selectedContract] - if (contractProperties && contractProperties[property]) return contractProperties[property] return null } @@ -167,7 +167,7 @@ export const ContractSelection = (props: ContractSelectionProps) => { } const details = () => { - _paq.push(['trackEvent', 'compiler', 'compilerDetails', 'display']) + trackMatomoEvent?.(CompilerEvents.compilerDetails('display')) if (!selectedContract) throw new Error('No contract compiled yet') const help = { @@ -236,7 +236,7 @@ export const ContractSelection = (props: ContractSelectionProps) => {
) const downloadFn = () => { - _paq.push(['trackEvent', 'compiler', 'compilerDetails', 'download']) + trackMatomoEvent?.(CompilerEvents.compilerDetails('download')) saveAs(new Blob([JSON.stringify(contractProperties, null, '\t')]), `${selectedContract}_compData.json`) } // modal(selectedContract, log, intl.formatMessage({id: 'solidity.download'}), downloadFn, true, intl.formatMessage({id: 'solidity.close'}), null) @@ -248,7 +248,7 @@ export const ContractSelection = (props: ContractSelectionProps) => { } const runStaticAnalysis = async () => { - _paq.push(['trackEvent', 'solidityCompiler', 'runStaticAnalysis', 'initiate']) + trackMatomoEvent?.(SolidityCompilerEvents.runStaticAnalysis('initiate')) const plugin = api as any const isStaticAnalyzersActive = await plugin.call('manager', 'isActive', 'solidityStaticAnalysis') if (!isStaticAnalyzersActive) { @@ -262,7 +262,7 @@ export const ContractSelection = (props: ContractSelectionProps) => { } const runSolidityScan = async () => { - _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'askPermissionToScan']) + trackMatomoEvent?.(SolidityCompilerEvents.solidityScan('askPermissionToScan')) const modal: AppModal = { id: 'SolidityScanPermissionHandler', title: , @@ -270,7 +270,7 @@ export const ContractSelection = (props: ContractSelectionProps) => { _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'learnMore'])}> + onClick={() => trackMatomoEvent?.(SolidityCompilerEvents.solidityScan('learnMore'))}> Learn more @@ -280,7 +280,7 @@ export const ContractSelection = (props: ContractSelectionProps) => { okLabel: , okFn: handleScanContinue, cancelLabel: , - cancelFn:() => { _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'cancelClicked'])} + cancelFn:() => { trackMatomoEvent?.(SolidityCompilerEvents.solidityScan('cancelClicked'))} } await (api as any).call('notification', 'modal', modal) } diff --git a/libs/remix-ui/solidity-compiler/src/lib/logic/compileTabLogic.ts b/libs/remix-ui/solidity-compiler/src/lib/logic/compileTabLogic.ts index 5dc0474a946..91e3c920766 100644 --- a/libs/remix-ui/solidity-compiler/src/lib/logic/compileTabLogic.ts +++ b/libs/remix-ui/solidity-compiler/src/lib/logic/compileTabLogic.ts @@ -3,13 +3,6 @@ import { getValidLanguage, Compiler } from '@remix-project/remix-solidity' import { EventEmitter } from 'events' import { configFileContent } from '../compilerConfiguration' -declare global { - interface Window { - _paq: any - } -} -const _paq = window._paq = window._paq || [] //eslint-disable-line - export class CompileTabLogic { public compiler public api: ICompilerApi @@ -183,7 +176,9 @@ export class CompileTabLogic { ` const configFilePath = 'remix-compiler.config.js' this.api.writeFile(configFilePath, fileContent) - _paq.push(['trackEvent', 'compiler', 'runCompile', 'compileWithHardhat']) + if (window._matomoManagerInstance) { + window._matomoManagerInstance.trackEvent('compiler', 'runCompile', 'compileWithHardhat') + } this.api.compileWithHardhat(configFilePath).then((result) => { this.api.logToTerminal({ type: 'log', value: result }) }).catch((error) => { @@ -209,7 +204,9 @@ export class CompileTabLogic { }` const configFilePath = 'remix-compiler.config.js' this.api.writeFile(configFilePath, fileContent) - _paq.push(['trackEvent', 'compiler', 'runCompile', 'compileWithTruffle']) + if (window._matomoManagerInstance) { + window._matomoManagerInstance.trackEvent('compiler', 'runCompile', 'compileWithTruffle') + } this.api.compileWithTruffle(configFilePath).then((result) => { this.api.logToTerminal({ type: 'log', value: result }) }).catch((error) => { diff --git a/libs/remix-ui/solidity-uml-gen/src/lib/components/UmlDownload.tsx b/libs/remix-ui/solidity-uml-gen/src/lib/components/UmlDownload.tsx index 09ffa3518e8..6b537d0166c 100644 --- a/libs/remix-ui/solidity-uml-gen/src/lib/components/UmlDownload.tsx +++ b/libs/remix-ui/solidity-uml-gen/src/lib/components/UmlDownload.tsx @@ -1,10 +1,10 @@ import { CustomTooltip } from '@remix-ui/helper' -import React, { Fragment, Ref } from 'react' +import React, { Fragment, Ref, useContext } from 'react' import { FormattedMessage } from 'react-intl' import { Dropdown } from 'react-bootstrap' import { UmlFileType } from '../utilities/UmlDownloadStrategy' - -const _paq = (window._paq = window._paq || []) +import { SolidityUMLGenEvents, SolUmlGenEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' export const Markup = React.forwardRef( ( @@ -65,6 +65,7 @@ interface UmlDownloadProps { } export default function UmlDownload(props: UmlDownloadProps) { + const { trackMatomoEvent } = useContext(TrackingContext) return ( { - _paq.push(['trackEvent', 'solidityumlgen', 'umlpngdownload', 'downloadAsPng']) + trackMatomoEvent?.(SolidityUMLGenEvents.umlpngdownload('downloadAsPng')) props.download('png') }} data-id="umlPngDownload" @@ -99,7 +100,7 @@ export default function UmlDownload(props: UmlDownloadProps) { { - _paq.push(['trackEvent', 'solUmlGen', 'umlpdfdownload', 'downloadAsPdf']) + trackMatomoEvent?.(SolUmlGenEvents.umlpdfdownload('downloadAsPdf')) props.download('pdf') }} data-id="umlPdfDownload" diff --git a/libs/remix-ui/solidity-unit-testing/src/lib/solidity-unit-testing.tsx b/libs/remix-ui/solidity-unit-testing/src/lib/solidity-unit-testing.tsx index 40f322a7084..963b33b3fd9 100644 --- a/libs/remix-ui/solidity-unit-testing/src/lib/solidity-unit-testing.tsx +++ b/libs/remix-ui/solidity-unit-testing/src/lib/solidity-unit-testing.tsx @@ -10,8 +10,8 @@ import { format } from 'util' import './css/style.css' import { CustomTooltip } from '@remix-ui/helper' import { appPlatformTypes, platformContext } from '@remix-ui/app' - -const _paq = ((window as any)._paq = (window as any)._paq || []) // eslint-disable-line @typescript-eslint/no-explicit-any +import { TrackingContext } from '@remix-ide/tracking' +import { SolidityUnitTestingEvents } from '@remix-api' interface TestObject { fileName: string @@ -45,6 +45,7 @@ interface FinalResult { export const SolidityUnitTesting = (props: Record) => { // eslint-disable-line @typescript-eslint/no-explicit-any const platform = useContext(platformContext) + const { trackMatomoEvent } = useContext(TrackingContext) const { helper, testTab, initialPath } = props const { testTabLogic } = testTab @@ -276,7 +277,7 @@ export const SolidityUnitTesting = (props: Record) => { } finalLogs = finalLogs + ' ' + formattedLog + '\n' } - _paq.push(['trackEvent', 'solidityUnitTesting', 'hardhat', 'console.log']) + trackMatomoEvent?.(SolidityUnitTestingEvents.hardhat('console.log')) testTab.call('terminal', 'logHtml', { type: 'log', value: finalLogs }) } @@ -662,7 +663,7 @@ export const SolidityUnitTesting = (props: Record) => { const tests: string[] = selectedTests.current if (!tests || !tests.length) return else setProgressBarHidden(false) - _paq.push(['trackEvent', 'solidityUnitTesting', 'runTests', 'nbTestsRunning' + tests.length]) + trackMatomoEvent?.(SolidityUnitTestingEvents.runTests('nbTestsRunning' + tests.length)) eachOfSeries(tests, (value: string, key: string, callback: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any if (hasBeenStopped.current) return diff --git a/libs/remix-ui/static-analyser/src/lib/actions/staticAnalysisActions.ts b/libs/remix-ui/static-analyser/src/lib/actions/staticAnalysisActions.ts index bc5a9ce84f9..ea7463f3361 100644 --- a/libs/remix-ui/static-analyser/src/lib/actions/staticAnalysisActions.ts +++ b/libs/remix-ui/static-analyser/src/lib/actions/staticAnalysisActions.ts @@ -3,6 +3,7 @@ import { CompilationResult, SourceWithTarget } from '@remixproject/plugin-api' import React from 'react' //eslint-disable-line import { AnalysisTab, RemixUiStaticAnalyserReducerActionType, RemixUiStaticAnalyserState, SolHintReport, SlitherAnalysisResults } from '../../staticanalyser' import { RemixUiStaticAnalyserProps } from '@remix-ui/static-analyser' +import { SolidityStaticAnalyzerEvents } from '@remix-api' /** * @@ -35,7 +36,7 @@ export const compilation = (analysisModule: AnalysisTab, * @param categoryIndex {number[]} * @param groupedModules {any} * @param runner {any} - * @param _paq {any} + * @param track {function} tracking function from AppContext * @param message {any} * @param showWarnings {boolean} * @param allWarnings {React.RefObject} @@ -43,7 +44,7 @@ export const compilation = (analysisModule: AnalysisTab, * @returns {Promise} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -export async function run (lastCompilationResult, lastCompilationSource, currentFile: string, state: RemixUiStaticAnalyserState, props: RemixUiStaticAnalyserProps, isSupportedVersion, showSlither, categoryIndex: number[], groupedModules, runner, _paq, message, showWarnings, allWarnings: React.RefObject, warningContainer: React.RefObject, calculateWarningStateEntries: (e:[string, any][]) => {length: number, errors: any[] }, warningState, setHints: React.Dispatch>, hints: SolHintReport[], setSlitherWarnings: React.Dispatch>, setSsaWarnings: React.Dispatch>, +export async function run (lastCompilationResult, lastCompilationSource, currentFile: string, state: RemixUiStaticAnalyserState, props: RemixUiStaticAnalyserProps, isSupportedVersion, showSlither, categoryIndex: number[], groupedModules, runner, trackMatomoEvent, message, showWarnings, allWarnings: React.RefObject, warningContainer: React.RefObject, calculateWarningStateEntries: (e:[string, any][]) => {length: number, errors: any[] }, warningState, setHints: React.Dispatch>, hints: SolHintReport[], setSlitherWarnings: React.Dispatch>, setSsaWarnings: React.Dispatch>, slitherEnabled: boolean, setStartAnalysis: React.Dispatch>, solhintEnabled: boolean, basicEnabled: boolean) { setStartAnalysis(true) setHints([]) @@ -57,7 +58,7 @@ export async function run (lastCompilationResult, lastCompilationSource, current props.analysisModule.hints = [] // Run solhint if (solhintEnabled) { - _paq.push(['trackEvent', 'solidityStaticAnalyzer', 'analyze', 'solHint']) + trackMatomoEvent?.(SolidityStaticAnalyzerEvents.analyze('solHint')) const hintsResult = await props.analysisModule.call('solhint', 'lint', state.file) props.analysisModule.hints = hintsResult setHints(hintsResult) @@ -67,7 +68,7 @@ export async function run (lastCompilationResult, lastCompilationSource, current } // Remix Analysis if (basicEnabled) { - _paq.push(['trackEvent', 'solidityStaticAnalyzer', 'analyze', 'remixAnalyzer']) + trackMatomoEvent?.(SolidityStaticAnalyzerEvents.analyze('remixAnalyzer')) const results = runner.run(lastCompilationResult, categoryIndex) for (const result of results) { let moduleName @@ -139,7 +140,7 @@ export async function run (lastCompilationResult, lastCompilationSource, current const compilerState = await props.analysisModule.call('solidity', 'getCompilerState') const { currentVersion, optimize, evmVersion } = compilerState await props.analysisModule.call('terminal', 'log', { type: 'log', value: '[Slither Analysis]: Running...' }) - _paq.push(['trackEvent', 'solidityStaticAnalyzer', 'analyze', 'slitherAnalyzer']) + trackMatomoEvent?.(SolidityStaticAnalyzerEvents.analyze('slitherAnalyzer')) const result: SlitherAnalysisResults = await props.analysisModule.call('slither', 'analyse', state.file, { currentVersion, optimize, evmVersion }) if (result.status) { props.analysisModule.call('terminal', 'log', { type: 'log', value: `[Slither Analysis]: Analysis Completed!! ${result.count} warnings found.` }) diff --git a/libs/remix-ui/static-analyser/src/lib/remix-ui-static-analyser.tsx b/libs/remix-ui/static-analyser/src/lib/remix-ui-static-analyser.tsx index 21eb59c566f..f8fdeacce6d 100644 --- a/libs/remix-ui/static-analyser/src/lib/remix-ui-static-analyser.tsx +++ b/libs/remix-ui/static-analyser/src/lib/remix-ui-static-analyser.tsx @@ -18,14 +18,8 @@ import { run } from './actions/staticAnalysisActions' import { BasicTitle, calculateWarningStateEntries } from './components/BasicTitle' import { Nav, TabContainer } from 'react-bootstrap' import { CustomTooltip } from '@remix-ui/helper' -import { appPlatformTypes, platformContext } from '@remix-ui/app' - -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) //eslint-disable-line +import { appPlatformTypes, platformContext, AppContext } from '@remix-ui/app' +import { TrackingContext } from '@remix-ide/tracking' /* eslint-disable-next-line */ export interface RemixUiStaticAnalyserProps { @@ -39,6 +33,8 @@ type tabSelectionType = 'remix' | 'solhint' | 'slither' | 'none' export const RemixUiStaticAnalyser = (props: RemixUiStaticAnalyserProps) => { const [runner] = useState(new CodeAnalysis()) const platform = useContext(platformContext) + const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const preProcessModules = (arr: any) => { return arr.map((Item, i) => { @@ -872,7 +868,7 @@ export const RemixUiStaticAnalyser = (props: RemixUiStaticAnalyserProps) => { categoryIndex, groupedModules, runner, - _paq, + trackMatomoEvent, message, showWarnings, allWarnings, @@ -908,7 +904,7 @@ export const RemixUiStaticAnalyser = (props: RemixUiStaticAnalyserProps) => { categoryIndex, groupedModules, runner, - _paq, + trackMatomoEvent, message, showWarnings, allWarnings, diff --git a/libs/remix-ui/statusbar/src/lib/components/gitStatus.tsx b/libs/remix-ui/statusbar/src/lib/components/gitStatus.tsx index e1dfd76baa5..604baed36ee 100644 --- a/libs/remix-ui/statusbar/src/lib/components/gitStatus.tsx +++ b/libs/remix-ui/statusbar/src/lib/components/gitStatus.tsx @@ -4,6 +4,7 @@ import { StatusBar } from 'apps/remix-ide/src/app/components/status-bar' import '../../css/statusbar.css' import { CustomTooltip } from '@remix-ui/helper' import { AppContext } from '@remix-ui/app' +import { trackMatomoEvent, GitEvents } from '@remix-api' export interface GitStatusProps { plugin: StatusBar @@ -20,7 +21,7 @@ export default function GitStatus({ plugin, gitBranchName, setGitBranchName }: G const initializeNewGitRepo = async () => { await plugin.call('dgit', 'init') - await plugin.call('matomo', 'track', ['trackEvent', 'statusBar', 'initNewRepo']); + trackMatomoEvent(plugin, GitEvents.INIT('initNewRepo')); } if (!appContext.appState.canUseGit) return null diff --git a/libs/remix-ui/statusbar/src/lib/components/scamDetails.tsx b/libs/remix-ui/statusbar/src/lib/components/scamDetails.tsx index ae031b6e534..16509dfab22 100644 --- a/libs/remix-ui/statusbar/src/lib/components/scamDetails.tsx +++ b/libs/remix-ui/statusbar/src/lib/components/scamDetails.tsx @@ -1,10 +1,10 @@ import { ExtendedRefs, ReferenceType } from '@floating-ui/react' -import React, { CSSProperties } from 'react' +import React, { CSSProperties, useContext } from 'react' import { FormattedMessage } from 'react-intl' import { ScamAlert } from '../remixui-statusbar-panel' import '../../css/statusbar.css' - -const _paq = (window._paq = window._paq || []) // eslint-disable-line +import { TrackingContext } from '@remix-ide/tracking' +import { HomeTabEvents } from '@remix-api' export interface ScamDetailsProps { refs: ExtendedRefs @@ -14,6 +14,7 @@ export interface ScamDetailsProps { } export default function ScamDetails ({ refs, floatStyle, scamAlerts }: ScamDetailsProps) { + const { trackMatomoEvent } = useContext(TrackingContext) return (
{ - index === 1 && _paq.push(['trackEvent', 'hometab', 'scamAlert', 'learnMore']) - index === 2 && _paq.push(['trackEvent', 'hometab', 'scamAlert', 'safetyTips']) + index === 1 && trackMatomoEvent?.(HomeTabEvents.scamAlert('learnMore')) + index === 2 && trackMatomoEvent?.(HomeTabEvents.scamAlert('safetyTips')) }} target="__blank" href={scamAlerts[index].url} diff --git a/libs/remix-ui/tabs/src/lib/components/CompileDropdown.tsx b/libs/remix-ui/tabs/src/lib/components/CompileDropdown.tsx index 2166eec5734..dc843b12e5e 100644 --- a/libs/remix-ui/tabs/src/lib/components/CompileDropdown.tsx +++ b/libs/remix-ui/tabs/src/lib/components/CompileDropdown.tsx @@ -1,13 +1,13 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useContext } from 'react' import DropdownMenu, { MenuItem } from './DropdownMenu' import { AppModal } from '@remix-ui/app' import { FormattedMessage } from 'react-intl' import { handleSolidityScan } from '@remix-ui/helper' +import { TrackingContext } from '@remix-ide/tracking' +import { SolidityCompilerEvents } from '@remix-api' import { ArrowRightBig, IpfsLogo, SwarmLogo, SettingsLogo, SolidityScanLogo, AnalysisLogo, TsLogo } from '@remix-ui/tabs' -const _paq = (window._paq = window._paq || []) - interface CompileDropdownProps { tabPath?: string plugin?: any @@ -19,6 +19,7 @@ interface CompileDropdownProps { } export const CompileDropdown: React.FC = ({ tabPath, plugin, disabled, onOpen, onRequestCompileAndPublish, compiledFileName, setCompileState }) => { + const { trackMatomoEvent } = useContext(TrackingContext) const [scriptFiles, setScriptFiles] = useState([]) const compileThen = async (nextAction: () => void, actionName: string) => { @@ -137,7 +138,7 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi } const runRemixAnalysis = async () => { - _paq.push(['trackEvent', 'solidityCompiler', 'staticAnalysis', 'initiate']) + trackMatomoEvent?.(SolidityCompilerEvents.staticAnalysis('initiate')) await compileThen(async () => { const isStaticAnalyzersActive = await plugin.call('manager', 'isActive', 'solidityStaticAnalysis') if (!isStaticAnalyzersActive) { @@ -156,7 +157,7 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi } const runSolidityScan = async () => { - _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'askPermissionToScan']) + trackMatomoEvent?.(SolidityCompilerEvents.solidityScan('askPermissionToScan')) const modal: AppModal = { id: 'SolidityScanPermissionHandler', title: , @@ -164,7 +165,7 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi _paq.push(['trackEvent', 'solidityCompiler', 'solidityScan', 'learnMore'])}> + onClick={() => trackMatomoEvent?.(SolidityCompilerEvents.solidityScan('learnMore'))}> Learn more
@@ -178,7 +179,7 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi } const openConfiguration = async () => { - _paq.push(['trackEvent', 'solidityCompiler', 'initiate']) + trackMatomoEvent?.(SolidityCompilerEvents.initiate()) const isSolidityCompilerActive = await plugin.call('manager', 'isActive', 'solidity') if (!isSolidityCompilerActive) { await plugin.call('manager', 'activatePlugin', 'solidity') diff --git a/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx b/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx index 42bc34637c5..9758af0bd3a 100644 --- a/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx +++ b/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx @@ -7,13 +7,12 @@ import { Tab, Tabs, TabList, TabPanel } from 'react-tabs' import './remix-ui-tabs.css' import { values } from 'lodash' import { AppContext } from '@remix-ui/app' -import { desktopConnectionType } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' +import { desktopConnectionType, EditorEvents } from '@remix-api' import { CompileDropdown, RunScriptDropdown } from '@remix-ui/tabs' // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries import TabProxy from 'apps/remix-ide/src/app/panels/tab-proxy' -const _paq = (window._paq = window._paq || []) - /* eslint-disable-next-line */ export interface TabsUIProps { tabs: Array @@ -86,6 +85,7 @@ export const TabsUI = (props: TabsUIProps) => { const tabs = useRef(props.tabs) tabs.current = props.tabs // we do this to pass the tabs list to the onReady callbacks const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const compileSeq = useRef(0) const compileWatchdog = useRef(null) @@ -259,7 +259,7 @@ export const TabsUI = (props: TabsUIProps) => { await props.plugin.call('menuicons', 'select', 'solidity') try { await props.plugin.call('solidity', 'compile', active().substr(active().indexOf('/') + 1, active().length)) - _paq.push(['trackEvent', 'editor', 'publishFromEditor', storageType]) + trackMatomoEvent?.(EditorEvents.publishFromEditor(storageType)) setTimeout(async () => { let buttonId @@ -316,7 +316,7 @@ export const TabsUI = (props: TabsUIProps) => { })()` await props.plugin.call('fileManager', 'writeFile', newScriptPath, boilerplateContent) - _paq.push(['trackEvent', 'editor', 'runScript', 'new_script']) + trackMatomoEvent?.(EditorEvents.runScript('new_script')) } catch (e) { console.error(e) props.plugin.call('notification', 'toast', `Error creating new script: ${e.message}`) @@ -346,7 +346,7 @@ export const TabsUI = (props: TabsUIProps) => { await props.plugin.call('scriptRunnerBridge', 'execute', content, path) setCompileState('compiled') - _paq.push(['trackEvent', 'editor', 'runScriptWithEnv', runnerKey]) + trackMatomoEvent?.(EditorEvents.runScriptWithEnv(runnerKey)) } catch (e) { console.error(e) props.plugin.call('notification', 'toast', `Error running script: ${e.message}`) @@ -426,7 +426,8 @@ export const TabsUI = (props: TabsUIProps) => { const handleCompileClick = async () => { setCompileState('compiling') - _paq.push(['trackEvent', 'editor', 'clickRunFromEditor', tabsState.currentExt]) + console.log('Compiling from editor') + trackMatomoEvent?.(EditorEvents.clickRunFromEditor(tabsState.currentExt)) try { const activePathRaw = active() diff --git a/libs/remix-ui/terminal/src/lib/components/RenderUnknownTransactions.tsx b/libs/remix-ui/terminal/src/lib/components/RenderUnknownTransactions.tsx index 5d9540839b7..0c598e2666b 100644 --- a/libs/remix-ui/terminal/src/lib/components/RenderUnknownTransactions.tsx +++ b/libs/remix-ui/terminal/src/lib/components/RenderUnknownTransactions.tsx @@ -1,12 +1,13 @@ -import React, {useState} from 'react' // eslint-disable-line +import React, {useState, useContext} from 'react' // eslint-disable-line import { FormattedMessage, useIntl } from 'react-intl' import CheckTxStatus from './ChechTxStatus' // eslint-disable-line import Context from './Context' // eslint-disable-line import showTable from './Table' -const _paq = window._paq = window._paq || [] +import { TrackingContext } from '@remix-ide/tracking' +import { UdappEvents } from '@remix-api' const RenderUnKnownTransactions = ({ tx, receipt, index, plugin, showTableHash, txDetails, modal, provider }) => { - + const { trackMatomoEvent } = useContext(TrackingContext) const intl = useIntl() const debug = (event, tx) => { event.stopPropagation() @@ -28,7 +29,7 @@ const RenderUnKnownTransactions = ({ tx, receipt, index, plugin, showTableHash, let to = tx.to if (tx.isUserOp) { - _paq.push(['trackEvent', 'udapp', 'safeSmartAccount', 'txExecutedSuccessfully']) + trackMatomoEvent?.(UdappEvents.safeSmartAccount('txExecuted', 'successfully')) // Track event with signature: ExecutionFromModuleSuccess (index_topic_1 address module) // to get sender smart account address const fromAddrLog = receipt.logs.find(e => e.topics[0] === "0x6895c13664aa4f67288b25d7a21d7aaa34916e355fb9b6fae0a139a9085becb8") diff --git a/libs/remix-ui/terminal/src/lib/remix-ui-terminal.tsx b/libs/remix-ui/terminal/src/lib/remix-ui-terminal.tsx index e4a7942819a..a2ad694348c 100644 --- a/libs/remix-ui/terminal/src/lib/remix-ui-terminal.tsx +++ b/libs/remix-ui/terminal/src/lib/remix-ui-terminal.tsx @@ -30,7 +30,6 @@ import parse from 'html-react-parser' import { EMPTY_BLOCK, KNOWN_TRANSACTION, RemixUiTerminalProps, SET_ISVM, SET_OPEN, UNKNOWN_TRANSACTION } from './types/terminalTypes' import { wrapScript } from './utils/wrapScript' import { TerminalContext } from './context' -const _paq = (window._paq = window._paq || []) /* eslint-disable-next-line */ export interface ClipboardEvent extends SyntheticEvent { diff --git a/libs/remix-ui/theme-module/types/theme-module.ts b/libs/remix-ui/theme-module/types/theme-module.ts index ed8a84cdab8..275b4ae3aab 100644 --- a/libs/remix-ui/theme-module/types/theme-module.ts +++ b/libs/remix-ui/theme-module/types/theme-module.ts @@ -10,7 +10,7 @@ export interface ThemeModule extends Plugin { config: any; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - _paq: any + element: HTMLDivElement; // eslint-disable-next-line @typescript-eslint/ban-types themes: {[key: string]: Theme}; diff --git a/libs/remix-ui/top-bar/src/components/gitLogin.tsx b/libs/remix-ui/top-bar/src/components/gitLogin.tsx index a1ce59245a8..8d6ec62f5c7 100644 --- a/libs/remix-ui/top-bar/src/components/gitLogin.tsx +++ b/libs/remix-ui/top-bar/src/components/gitLogin.tsx @@ -3,8 +3,8 @@ import React, { useContext, useCallback } from 'react' import { Button, ButtonGroup, Dropdown } from 'react-bootstrap' import { CustomTopbarMenu } from '@remix-ui/helper' import { AppContext } from '@remix-ui/app' - -const _paq = window._paq || [] +import { TopBarEvents } from '@remix-api' +import { TrackingContext } from '@remix-ide/tracking' interface GitHubLoginProps { cloneGitRepository: () => void @@ -20,6 +20,7 @@ export const GitHubLogin: React.FC = ({ loginWithGitHub }) => { const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) // Get the GitHub user state from app context const gitHubUser = appContext?.appState?.gitHubUser @@ -89,7 +90,7 @@ export const GitHubLogin: React.FC = ({ data-id="github-dropdown-item-publish-to-gist" onClick={async () => { await publishToGist() - _paq.push(['trackEvent', 'topbar', 'GIT', 'publishToGist']) + trackMatomoEvent?.(TopBarEvents.GIT('publishToGist')) }} > @@ -100,7 +101,7 @@ export const GitHubLogin: React.FC = ({ data-id="github-dropdown-item-disconnect" onClick={async () => { await logOutOfGithub() - _paq.push(['trackEvent', 'topbar', 'GIT', 'logout']) + trackMatomoEvent?.(TopBarEvents.GIT('logout')) }} className="text-danger" > diff --git a/libs/remix-ui/top-bar/src/lib/remix-ui-topbar.tsx b/libs/remix-ui/top-bar/src/lib/remix-ui-topbar.tsx index 084c1dff026..e4e900934eb 100644 --- a/libs/remix-ui/top-bar/src/lib/remix-ui-topbar.tsx +++ b/libs/remix-ui/top-bar/src/lib/remix-ui-topbar.tsx @@ -15,14 +15,15 @@ import { GitHubUser } from 'libs/remix-api/src/lib/types/git' import { GitHubCallback } from '../topbarUtils/gitOauthHandler' import { GitHubLogin } from '../components/gitLogin' import { CustomTooltip } from 'libs/remix-ui/helper/src/lib/components/custom-tooltip' - -const _paq = window._paq || [] +import { TrackingContext } from '@remix-ide/tracking' +import { TopBarEvents, WorkspaceEvents } from '@remix-api' export function RemixUiTopbar() { const intl = useIntl() const [showDropdown, setShowDropdown] = useState(false) const platform = useContext(platformContext) const global = useContext(TopbarContext) + const { trackMatomoEvent } = useContext(TrackingContext) const plugin = global.plugin const LOCALHOST = ' - connect to localhost - ' const NO_WORKSPACE = ' - none - ' @@ -288,13 +289,13 @@ export function RemixUiTopbar() { const loginWithGitHub = async () => { global.plugin.call('dgit', 'login') - _paq.push(['trackEvent', 'topbar', 'GIT', 'login']) + trackMatomoEvent?.(TopBarEvents.header('Settings')) } const logOutOfGithub = async () => { global.plugin.call('dgit', 'logOut') - _paq.push(['trackEvent', 'topbar', 'GIT', 'logout']) + trackMatomoEvent?.(TopBarEvents.GIT('logout')) } const handleTypingUrl = () => { @@ -386,7 +387,7 @@ export function RemixUiTopbar() { try { await switchToWorkspace(name) handleExpandPath([]) - _paq.push(['trackEvent', 'Workspace', 'switchWorkspace', name]) + trackMatomoEvent?.(WorkspaceEvents.switchWorkspace(name)) } catch (e) { global.modal( intl.formatMessage({ id: 'filePanel.workspace.switch' }), @@ -464,7 +465,7 @@ export function RemixUiTopbar() { className="d-flex align-items-center justify-content-between me-3 cursor-pointer" onClick={async () => { await plugin.call('tabs', 'focus', 'home') - _paq.push(['trackEvent', 'topbar', 'header', 'Home']) + trackMatomoEvent?.(TopBarEvents.header('Home')) }} data-id="verticalIconsHomeIcon" > @@ -474,7 +475,7 @@ export function RemixUiTopbar() { className="remixui_homeIcon" onClick={async () => { await plugin.call('tabs', 'focus', 'home') - _paq.push(['trackEvent', 'topbar', 'header', 'Home']) + trackMatomoEvent?.(TopBarEvents.header('Home')) }} > @@ -484,7 +485,7 @@ export function RemixUiTopbar() { style={{ fontSize: '1.2rem' }} onClick={async () => { await plugin.call('tabs', 'focus', 'home') - _paq.push(['trackEvent', 'topbar', 'header', 'Home']) + trackMatomoEvent?.(TopBarEvents.header('Home')) }} > Remix @@ -607,7 +608,7 @@ export function RemixUiTopbar() { const isActive = await plugin.call('manager', 'isActive', 'settings') if (!isActive) await plugin.call('manager', 'activatePlugin', 'settings') await plugin.call('tabs', 'focus', 'settings') - _paq.push(['trackEvent', 'topbar', 'header', 'Settings']) + trackMatomoEvent?.(TopBarEvents.header('Settings')) }} data-id="topbar-settingsIcon" > diff --git a/libs/remix-ui/vyper-compile-details/src/lib/vyperCompile.tsx b/libs/remix-ui/vyper-compile-details/src/lib/vyperCompile.tsx index 6179fa2d5bb..3aa6e4e7108 100644 --- a/libs/remix-ui/vyper-compile-details/src/lib/vyperCompile.tsx +++ b/libs/remix-ui/vyper-compile-details/src/lib/vyperCompile.tsx @@ -5,7 +5,6 @@ import Tabs from 'react-bootstrap/Tabs' import Tab from 'react-bootstrap/Tab' import Button from 'react-bootstrap/Button' import { ABIDescription } from '@remixproject/plugin-api' -const _paq = (window._paq = window._paq || []) export interface VyperCompilationResult { status?: 'success' diff --git a/libs/remix-ui/workspace/src/lib/actions/index.tsx b/libs/remix-ui/workspace/src/lib/actions/index.tsx index 0c87e6b5977..1cbfe10cb58 100644 --- a/libs/remix-ui/workspace/src/lib/actions/index.tsx +++ b/libs/remix-ui/workspace/src/lib/actions/index.tsx @@ -3,6 +3,7 @@ import React from 'react' import { extractNameFromKey, createNonClashingNameAsync } from '@remix-ui/helper' import Gists from 'gists' import { customAction } from '@remixproject/plugin-api' +import { trackMatomoEventAsync, StorageEvents, BackupEvents } from '@remix-api' import { displayNotification, displayPopUp, fetchDirectoryError, fetchDirectoryRequest, fetchDirectorySuccess, focusElement, fsInitializationCompleted, hidePopUp, removeInputFieldSuccess, setCurrentLocalFilePath, setCurrentWorkspace, setExpandPath, setMode, setWorkspaces } from './payload' import { listenOnPluginEvents, listenOnProviderEvents } from './events' import { createWorkspaceTemplate, getWorkspaces, loadWorkspacePreset, setPlugin, workspaceExists } from './workspace' @@ -18,7 +19,6 @@ export * from './events' export * from './workspace' const queryParams = new QueryParams() -const _paq = window._paq = window._paq || [] let plugin, dispatch: React.Dispatch @@ -200,7 +200,7 @@ export const initWorkspace = (filePanelPlugin) => async (reducerDispatch: React. plugin.setWorkspace({ name: name, isLocalhost: false }) dispatch(setCurrentWorkspace({ name: name, isGitRepo: false })) } else { - _paq.push(['trackEvent', 'Storage', 'error', `Workspace in localstorage not found: ${localStorage.getItem("currentWorkspace")}`]) + await trackMatomoEventAsync(plugin, StorageEvents.error(`Workspace in localstorage not found: ${localStorage.getItem("currentWorkspace")}`)); await basicWorkspaceInit(workspaces, workspaceProvider) } } else { @@ -367,7 +367,7 @@ export const initWorkspace = (filePanelPlugin) => async (reducerDispatch: React. plugin.setWorkspace({ name: name, isLocalhost: false }) dispatch(setCurrentWorkspace({ name: name, isGitRepo: false })) } else { - _paq.push(['trackEvent', 'Storage', 'error', `Workspace in localstorage not found: ${localStorage.getItem("currentWorkspace")}`]) + await trackMatomoEventAsync(plugin, StorageEvents.error(`Workspace in localstorage not found: ${localStorage.getItem("currentWorkspace")}`)); await basicWorkspaceInit(workspaces, workspaceProvider) } } else { @@ -726,15 +726,15 @@ export const handleDownloadFiles = async () => { await browserProvider.copyFolderToJson('/', ({ path, content }) => { zip.file(path, content) }) - zip.generateAsync({ type: 'blob' }).then(function (blob) { + zip.generateAsync({ type: 'blob' }).then(async function (blob) { const today = new Date() const date = today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate() const time = today.getHours() + 'h' + today.getMinutes() + 'min' saveAs(blob, `remix-backup-at-${time}-${date}.zip`) - _paq.push(['trackEvent', 'Backup', 'download', 'home']) - }).catch((e) => { - _paq.push(['trackEvent', 'Backup', 'error', e.message]) + await trackMatomoEventAsync(plugin, BackupEvents.download('home')); + }).catch(async (e) => { + await trackMatomoEventAsync(plugin, BackupEvents.error(e.message)); plugin.call('notification', 'toast', e.message) }) } catch (e) { @@ -760,7 +760,7 @@ export const handleDownloadWorkspace = async () => { export const restoreBackupZip = async () => { await plugin.appManager.activatePlugin(['restorebackupzip']) await plugin.call('mainPanel', 'showContent', 'restorebackupzip') - _paq.push(['trackEvent', 'Backup', 'userActivate', 'restorebackupzip']) + await trackMatomoEventAsync(plugin, BackupEvents.userActivate('restorebackupzip')); } const packageGistFiles = async (directory) => { diff --git a/libs/remix-ui/workspace/src/lib/actions/workspace.ts b/libs/remix-ui/workspace/src/lib/actions/workspace.ts index 0b88bdfc5e5..509c3f11039 100644 --- a/libs/remix-ui/workspace/src/lib/actions/workspace.ts +++ b/libs/remix-ui/workspace/src/lib/actions/workspace.ts @@ -1,5 +1,6 @@ import React from 'react' import { bytesToHex } from '@ethereumjs/util' +import { trackMatomoEventAsync, WorkspaceEvents, CompilerEvents } from '@remix-api' import { hash } from '@remix-project/remix-lib' import { createNonClashingNameAsync } from '@remix-ui/helper' import { TEMPLATE_METADATA, TEMPLATE_NAMES } from '../utils/constants' @@ -60,7 +61,6 @@ const LOCALHOST = ' - connect to localhost - ' const NO_WORKSPACE = ' - none - ' const ELECTRON = 'electron' const queryParams = new QueryParams() -const _paq = (window._paq = window._paq || []) //eslint-disable-line let plugin: any, dgitPlugin: Plugin,dispatch: React.Dispatch export const setPlugin = (filePanelPlugin, reducerDispatch) => { @@ -239,12 +239,12 @@ export const populateWorkspace = async ( if (workspaceTemplateName === 'semaphore' || workspaceTemplateName === 'hashchecker' || workspaceTemplateName === 'rln') { const isCircomActive = await plugin.call('manager', 'isActive', 'circuit-compiler') if (!isCircomActive) await plugin.call('manager', 'activatePlugin', 'circuit-compiler') - _paq.push(['trackEvent', 'circuit-compiler', 'template', 'create', workspaceTemplateName]) + await trackMatomoEventAsync(plugin, CompilerEvents.compiled(workspaceTemplateName)) } if (workspaceTemplateName === 'multNr' || workspaceTemplateName === 'stealthDropNr') { const isNoirActive = await plugin.call('manager', 'isActive', 'noir-compiler') if (!isNoirActive) await plugin.call('manager', 'activatePlugin', 'noir-compiler') - _paq.push(['trackEvent', 'noir-compiler', 'template', 'create', workspaceTemplateName]) + await trackMatomoEventAsync(plugin, CompilerEvents.compiled(workspaceTemplateName)) } } @@ -301,7 +301,7 @@ export const loadWorkspacePreset = async (template: WorkspaceTemplate = 'remixDe let content if (params.code) { - _paq.push(['trackEvent', 'workspace', 'template', 'code-template-code-param']) + await trackMatomoEventAsync(plugin, WorkspaceEvents.switchWorkspace('code-template-code-param')) const hashed = bytesToHex(hash.keccakFromString(params.code)) path = 'contract-' + hashed.replace('0x', '').substring(0, 10) + (params.language && params.language.toLowerCase() === 'yul' ? '.yul' : '.sol') @@ -309,7 +309,7 @@ export const loadWorkspacePreset = async (template: WorkspaceTemplate = 'remixDe await workspaceProvider.set(path, content) } if (params.shareCode) { - _paq.push(['trackEvent', 'workspace', 'template', 'code-template-shareCode-param']) + await trackMatomoEventAsync(plugin, WorkspaceEvents.switchWorkspace('code-template-shareCode-param')) const host = '127.0.0.1' const port = 5001 const protocol = 'http' @@ -334,7 +334,7 @@ export const loadWorkspacePreset = async (template: WorkspaceTemplate = 'remixDe await workspaceProvider.set(path, content) } if (params.url) { - _paq.push(['trackEvent', 'workspace', 'template', 'code-template-url-param']) + await trackMatomoEventAsync(plugin, WorkspaceEvents.switchWorkspace('code-template-url-param')) const data = await plugin.call('contentImport', 'resolve', params.url) path = data.cleanUrl content = data.content @@ -358,7 +358,7 @@ export const loadWorkspacePreset = async (template: WorkspaceTemplate = 'remixDe } if (params.ghfolder) { try { - _paq.push(['trackEvent', 'workspace', 'template', 'code-template-ghfolder-param']) + await trackMatomoEventAsync(plugin, WorkspaceEvents.switchWorkspace('code-template-ghfolder-param')) const files = await plugin.call('contentImport', 'resolveGithubFolder', params.ghfolder) for (const [path, content] of Object.entries(files)) { await workspaceProvider.set(path, content) @@ -377,7 +377,7 @@ export const loadWorkspacePreset = async (template: WorkspaceTemplate = 'remixDe case 'gist-template': // creates a new workspace gist-sample and get the file from gist try { - _paq.push(['trackEvent', 'workspace', 'template', 'gist-template']) + await trackMatomoEventAsync(plugin, WorkspaceEvents.switchWorkspace('gist-template')) const gistId = params.gist const response: AxiosResponse = await axios.get(`https://api.github.com/gists/${gistId}`) const data = response.data as { files: any } @@ -440,7 +440,7 @@ export const loadWorkspacePreset = async (template: WorkspaceTemplate = 'remixDe const templateList = Object.keys(templateWithContent) if (!templateList.includes(template)) break - _paq.push(['trackEvent', 'workspace', 'template', template]) + await trackMatomoEventAsync(plugin, WorkspaceEvents.switchWorkspace(template)) // @ts-ignore const files = await templateWithContent[template](opts, plugin) for (const file in files) { diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx index bf86f9d8adf..8915f7e5c6d 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx @@ -5,17 +5,14 @@ import { action, FileExplorerContextMenuProps } from '../types' import '../css/file-explorer-context-menu.css' import { customAction } from '@remixproject/plugin-api' import UploadFile from './upload-file' -import { appPlatformTypes, platformContext } from '@remix-ui/app' - -declare global { - interface Window { - _paq: any - } -} -const _paq = (window._paq = window._paq || []) //eslint-disable-line +import { appPlatformTypes, platformContext, AppContext } from '@remix-ui/app' +import { TrackingContext } from '@remix-ide/tracking' +import { FileExplorerEvent, FileExplorerEvents } from '@remix-api' export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => { const platform = useContext(platformContext) + const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const { actions, createNewFile, @@ -127,7 +124,7 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => key={key} className={className} onClick={() => { - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'uploadFile']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('uploadFile')) setShowFileExplorer(true) }} > @@ -147,7 +144,7 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => key={key} className={className} onClick={() => { - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'uploadFile']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('uploadFile')) setShowFileExplorer(true) }} > @@ -169,78 +166,78 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => switch (item.name) { case 'New File': createNewFile(path) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'newFile']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('newFile')) break case 'New Folder': createNewFolder(path) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'newFolder']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('newFolder')) break case 'Rename': renamePath(path, type) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'rename']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('rename')) break case 'Delete': deletePath(getPath()) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'delete']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('delete')) break case 'Download': downloadPath(path) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'download']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('download')) break case 'Push changes to gist': - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'pushToChangesoGist']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('pushToChangesoGist')) pushChangesToGist(path) break case 'Publish folder to gist': - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'publishFolderToGist']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('publishFolderToGist')) publishFolderToGist(path) break case 'Publish file to gist': - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'publishFileToGist']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('publishFileToGist')) publishFileToGist(path) break case 'Publish files to gist': - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'publishFilesToGist']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('publishFilesToGist')) publishManyFilesToGist() break case 'Run': - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'runScript']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('runScript')) runScript(path) break case 'Copy': copy(path, type) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'copy']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('copy')) break case 'Copy name': copyFileName(path, type) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'copyName']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('copyName')) break case 'Copy path': copyPath(path, type) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'copyPath']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('copyPath')) break case 'Copy share URL': copyShareURL(path, type) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'copyShareURL']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('copyShareURL')) break case 'Paste': paste(path, type) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'paste']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('paste')) break case 'Delete All': deletePath(getPath()) - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'deleteAll']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('deleteAll')) break case 'Publish Workspace to Gist': - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'publishWorkspace']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('publishWorkspace')) publishFolderToGist(path) break case 'Sign Typed Data': - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', 'signTypedData']) + trackMatomoEvent?.(FileExplorerEvents.contextMenu('signTypedData')) signTypedData(path) break default: - _paq.push(['trackEvent', 'fileExplorer', 'contextMenu', `${item.id}/${item.name}`]) + trackMatomoEvent?.(FileExplorerEvents.contextMenu(`${item.id}/${item.name}`)) emit && emit({ ...item, path: [path]} as customAction) break } diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx index 37d08b5bee7..93539753ca8 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx @@ -5,11 +5,13 @@ import { Placement } from 'react-bootstrap/esm/types' import { FileExplorerMenuProps } from '../types' import { FileSystemContext } from '../contexts' import { appPlatformTypes, platformContext } from '@remix-ui/app' -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { FileExplorerEvents } from '@remix-api' export const FileExplorerMenu = (props: FileExplorerMenuProps) => { const global = useContext(FileSystemContext) const platform = useContext(platformContext) + const { trackMatomoEvent } = useContext(TrackingContext) const [state, setState] = useState({ menuItems: [ { @@ -102,7 +104,7 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { type="file" onChange={(e) => { e.stopPropagation() - _paq.push(['trackEvent', 'fileExplorer', 'fileAction', action]) + trackMatomoEvent?.(FileExplorerEvents.fileAction(action)) props.uploadFile(e.target) e.target.value = null }} @@ -133,7 +135,7 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { type="file" onChange={(e) => { e.stopPropagation() - _paq.push(['trackEvent', 'fileExplorer', 'fileAction', action]) + trackMatomoEvent?.(FileExplorerEvents.fileAction(action)) props.uploadFolder(e.target) e.target.value = null }} @@ -159,7 +161,7 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { className={icon + ' mx-1 remixui_menuItem'} key={`index-${action}-${placement}-${icon}`} onClick={() => { - _paq.push(['trackEvent', 'fileExplorer', 'fileAction', action]) + trackMatomoEvent?.(FileExplorerEvents.fileAction(action)) props.handleGitInit() }} > @@ -181,7 +183,7 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { data-id={'fileExplorerNewFile' + action} onClick={(e) => { e.stopPropagation() - _paq.push(['trackEvent', 'fileExplorer', 'fileAction', action]) + trackMatomoEvent?.(FileExplorerEvents.fileAction(action)) if (action === 'createNewFile') { props.createNewFile() } else if (action === 'createNewFolder') { @@ -189,10 +191,10 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { } else if (action === 'publishToGist' || action == 'updateGist') { props.publishToGist() } else if (action === 'importFromIpfs') { - _paq.push(['trackEvent', 'fileExplorer', 'fileAction', action]) + trackMatomoEvent?.(FileExplorerEvents.fileAction(action)) props.importFromIpfs('Ipfs', 'ipfs hash', ['ipfs://QmQQfBMkpDgmxKzYaoAtqfaybzfgGm9b2LWYyT56Chv6xH'], 'ipfs://') } else if (action === 'importFromHttps') { - _paq.push(['trackEvent', 'fileExplorer', 'fileAction', action]) + trackMatomoEvent?.(FileExplorerEvents.fileAction(action)) props.importFromHttps('Https', 'http/https raw content', ['https://raw.githubusercontent.com/OpenZeppelin/openzeppelin-contracts/master/contracts/token/ERC20/ERC20.sol']) } else { state.actions[action]() diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx index fa82ef2274d..5cdc92617ed 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx @@ -12,6 +12,9 @@ import { ROOT_PATH } from '../utils/constants' import { copyFile, moveFileIsAllowed, moveFilesIsAllowed, moveFolderIsAllowed, moveFoldersIsAllowed } from '../actions' import { FlatTree } from './flat-tree' import { FileSystemContext } from '../contexts' +import { AppContext } from '@remix-ui/app' +import { TrackingContext } from '@remix-ide/tracking' +import { FileExplorerEvents } from '@remix-api' export const FileExplorer = (props: FileExplorerProps) => { const intl = useIntl() @@ -46,6 +49,8 @@ export const FileExplorer = (props: FileExplorerProps) => { const [cutActivated, setCutActivated] = useState(false) const { plugin } = useContext(FileSystemContext) + const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const [filesSelected, setFilesSelected] = useState([]) const feWindow = (window as any) @@ -123,7 +128,7 @@ export const FileExplorer = (props: FileExplorerProps) => { if (treeRef.current) { const deleteKeyPressHandler = async (eve: KeyboardEvent) => { if (eve.key === 'Delete' ) { - feWindow._paq.push(['trackEvent', 'fileExplorer', 'deleteKey', 'deletePath']) + trackMatomoEvent?.(FileExplorerEvents.deleteKey('deletePath')) setState((prevState) => { return { ...prevState, deleteKey: true } }) @@ -132,7 +137,7 @@ export const FileExplorer = (props: FileExplorerProps) => { } if (eve.metaKey) { if (eve.key === 'Backspace') { - feWindow._paq.push(['trackEvent', 'fileExplorer', 'osxDeleteKey', 'deletePath']) + trackMatomoEvent?.(FileExplorerEvents.osxDeleteKey('deletePath')) setState((prevState) => { return { ...prevState, deleteKey: true } }) @@ -178,7 +183,7 @@ export const FileExplorer = (props: FileExplorerProps) => { if (treeRef.current) { const F2KeyPressHandler = async (eve: KeyboardEvent) => { if (eve.key === 'F2' ) { - feWindow._paq.push(['trackEvent', 'fileExplorer', 'f2ToRename', 'RenamePath']) + trackMatomoEvent?.(FileExplorerEvents.f2ToRename('RenamePath')) await performRename() setState((prevState) => { return { ...prevState, F2Key: true } @@ -267,7 +272,7 @@ export const FileExplorer = (props: FileExplorerProps) => { const CopyComboHandler = async (eve: KeyboardEvent) => { if ((eve.metaKey || eve.ctrlKey) && (eve.key === 'c' || eve.code === 'KeyC')) { await performCopy() - feWindow._paq.push(['trackEvent', 'fileExplorer', 'copyCombo', 'copyFilesOrFile']) + trackMatomoEvent?.(FileExplorerEvents.copyCombo('copyFilesOrFile')) return } } @@ -275,7 +280,7 @@ export const FileExplorer = (props: FileExplorerProps) => { const CutHandler = async (eve: KeyboardEvent) => { if ((eve.metaKey || eve.ctrlKey) && (eve.key === 'x' || eve.code === 'KeyX')) { await performCut() - feWindow._paq.push(['trackEvent', 'fileExplorer', 'cutCombo', 'cutFilesOrFile']) + trackMatomoEvent?.(FileExplorerEvents.cutCombo('cutFilesOrFile')) return } } @@ -283,7 +288,7 @@ export const FileExplorer = (props: FileExplorerProps) => { const pasteHandler = async (eve: KeyboardEvent) => { if ((eve.metaKey || eve.ctrlKey) && (eve.key === 'v' || eve.code === 'KeyV')) { performPaste() - feWindow._paq.push(['trackEvent', 'fileExplorer', 'pasteCombo', 'PasteCopiedContent']) + trackMatomoEvent?.(FileExplorerEvents.pasteCombo('PasteCopiedContent')) return } } diff --git a/libs/remix-ui/workspace/src/lib/components/workspace-hamburger-item.tsx b/libs/remix-ui/workspace/src/lib/components/workspace-hamburger-item.tsx index e93e27797ae..57b757cc6c6 100644 --- a/libs/remix-ui/workspace/src/lib/components/workspace-hamburger-item.tsx +++ b/libs/remix-ui/workspace/src/lib/components/workspace-hamburger-item.tsx @@ -3,7 +3,8 @@ import { CustomTooltip, CustomMenu, CustomIconsToggle } from '@remix-ui/helper' import { Dropdown, NavDropdown } from 'react-bootstrap' import { FormattedMessage } from 'react-intl' import { appPlatformTypes, platformContext } from '@remix-ui/app' -const _paq = (window._paq = window._paq || []) +import { TrackingContext } from '@remix-ide/tracking' +import { FileExplorerEvents } from '@remix-api' export interface HamburgerMenuItemProps { hideOption: boolean @@ -16,6 +17,7 @@ export interface HamburgerMenuItemProps { export function HamburgerMenuItem(props: HamburgerMenuItemProps) { const { hideOption } = props const platform = useContext(platformContext) + const { trackMatomoEvent } = useContext(TrackingContext) const uid = 'workspace' + props.kind return ( <> @@ -27,7 +29,7 @@ export function HamburgerMenuItem(props: HamburgerMenuItemProps) { key={uid + '-fe-ws'} onClick={() => { props.actionOnClick() - _paq.push(['trackEvent', 'fileExplorer', 'workspaceMenu', uid]) + trackMatomoEvent?.(FileExplorerEvents.workspaceMenu(uid)) }} > @@ -44,6 +46,7 @@ export function HamburgerMenuItem(props: HamburgerMenuItemProps) { // keeping the following for a later use: export function NavHamburgerMenuItem(props: HamburgerMenuItemProps) { const { hideOption } = props + const { trackMatomoEvent } = useContext(TrackingContext) const uid = 'workspace' + props.kind return ( <> @@ -54,7 +57,7 @@ export function NavHamburgerMenuItem(props: HamburgerMenuItemProps) { key={uid + '-fe-ws'} onClick={() => { props.actionOnClick() - _paq.push(['trackEvent', 'fileExplorer', 'workspaceMenu', uid]) + trackMatomoEvent?.(FileExplorerEvents.workspaceMenu(uid)) }} > diff --git a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx index e6e9906f02d..92915a2c476 100644 --- a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx +++ b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx @@ -15,14 +15,14 @@ import { contextMenuActions } from './utils' import FileExplorerContextMenu from './components/file-explorer-context-menu' import { customAction } from '@remixproject/plugin-api' import { AppContext, appPlatformTypes, platformContext } from '@remix-ui/app' +import { TrackingContext } from '@remix-ide/tracking' +import { HomeTabEvents, WorkspaceEvents } from '@remix-api' import { ElectronMenu } from './components/electron-menu' import { ElectronWorkspaceName } from './components/electron-workspace-name' import { branch } from '@remix-api' import { gitUIPanels } from '@remix-ui/git' import { createModalMessage } from './components/createModal' -const _paq = (window._paq = window._paq || []) - const canUpload = window.File || window.FileReader || window.FileList || window.Blob export function Workspace() { @@ -52,6 +52,7 @@ export function Workspace() { const [canPaste, setCanPaste] = useState(false) const appContext = useContext(AppContext) + const { trackMatomoEvent } = useContext(TrackingContext) const [state, setState] = useState({ ctrlKey: false, @@ -218,7 +219,7 @@ export function Workspace() { )) const processLoading = (type: string) => { - _paq.push(['trackEvent', 'hometab', 'filesSection', 'importFrom' + type]) + trackMatomoEvent?.(HomeTabEvents.filesSection('importFrom' + type)) const contentImport = global.plugin.contentImport const workspace = global.plugin.fileManager.getProvider('workspace') const startsWith = modalState.importSource.substring(0, 4) @@ -521,7 +522,7 @@ export function Workspace() { try { await global.dispatchSwitchToWorkspace(name) global.dispatchHandleExpandPath([]) - _paq.push(['trackEvent', 'Workspace', 'switchWorkspace', name]) + trackMatomoEvent?.(WorkspaceEvents.switchWorkspace(name)) } catch (e) { global.modal( intl.formatMessage({ id: 'filePanel.workspace.switch' }), @@ -859,10 +860,10 @@ export function Workspace() { try { if (branch.remote) { await global.dispatchCheckoutRemoteBranch(branch) - _paq.push(['trackEvent', 'Workspace', 'GIT', 'checkout_remote_branch']) + trackMatomoEvent?.(WorkspaceEvents.GIT('checkout_remote_branch')) } else { await global.dispatchSwitchToBranch(branch) - _paq.push(['trackEvent', 'Workspace', 'GIT', 'switch_to_exisiting_branch']) + trackMatomoEvent?.(WorkspaceEvents.GIT('switch_to_existing_branch')) } } catch (e) { console.error(e) @@ -879,7 +880,7 @@ export function Workspace() { const switchToNewBranch = async () => { try { await global.dispatchCreateNewBranch(branchFilter) - _paq.push(['trackEvent', 'Workspace', 'GIT', 'switch_to_new_branch']) + trackMatomoEvent?.(WorkspaceEvents.GIT('switch_to_new_branch')) } catch (e) { global.modal( intl.formatMessage({ id: 'filePanel.checkoutGitBranch' }), @@ -923,7 +924,7 @@ export function Workspace() { const logInGithub = async () => { await global.plugin.call('menuicons', 'select', 'dgit'); await global.plugin.call('dgit', 'open', gitUIPanels.GITHUB) - _paq.push(['trackEvent', 'Workspace', 'GIT', 'login']) + trackMatomoEvent?.(WorkspaceEvents.GIT('login')) } const IsGitRepoDropDownMenuItem = (props: { isGitRepo: boolean, mName: string}) => { diff --git a/tsconfig.paths.json b/tsconfig.paths.json index c3429db21b6..5f0c8776cc1 100644 --- a/tsconfig.paths.json +++ b/tsconfig.paths.json @@ -208,6 +208,9 @@ ], "@remix-ui/top-bar": [ "libs/remix-ui/top-bar/src/index.ts" + ], + "@remix-ide/tracking": [ + "apps/remix-ide/src/app/contexts/TrackingContext" ] } }