import ScreenshotEditor from './screenshot-editor.js'; class FeedbackExtension { constructor() { this.screenshot = null; this.editorWindow = null; this.mediaRecorder = null; this.recordedChunks = []; this.recordingDialog = null; this.recordedVideo = null; // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', () => { this.initializeElements(); this.attachEventListeners(); this.loadSettings(); }); // Listen for messages from the editor window chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { console.log('Popup received message:', message); try { if (message.type === 'screenshot-saved') { this.screenshot = message.screenshot; this.attachmentPreview.innerHTML = `
Screenshot
`; // Close the editor window if it exists if (this.editorWindow && !this.editorWindow.closed) { this.editorWindow.close(); } sendResponse({ success: true }); } else if (message.type === 'editor-ready') { console.log('Editor is ready, sending screenshot...'); if (this.screenshot) { chrome.runtime.sendMessage({ type: 'init-editor', screenshot: this.screenshot }, response => { console.log('Screenshot sent to editor, response:', response); }); } sendResponse({ success: true }); } } catch (error) { console.error('Error handling message:', error); sendResponse({ success: false, error: error.message }); } return true; // Keep the message channel open for async response }); } initializeElements() { // Form elements this.feedbackForm = document.getElementById('feedback-form'); this.settingsPanel = document.getElementById('settings-panel'); this.screenshotBtn = document.getElementById('screenshot-btn'); this.recordBtn = document.getElementById('record-btn'); this.submitFeedbackBtn = document.getElementById('submit-feedback'); this.settingsBtn = document.getElementById('settings-btn'); this.saveSettingsBtn = document.getElementById('save-settings'); this.attachmentPreview = document.getElementById('attachment-preview'); // Settings inputs this.jiraDomainInput = document.getElementById('jira-domain'); this.jiraEmailInput = document.getElementById('jira-email'); this.jiraTokenInput = document.getElementById('jira-token'); this.jiraProjectInput = document.getElementById('jira-project'); // Notification this.notification = document.getElementById('notification'); // Create recording dialog this.recordingDialog = document.createElement('div'); this.recordingDialog.className = 'recording-dialog hidden'; this.recordingDialog.innerHTML = `
Recording...
`; document.body.appendChild(this.recordingDialog); } attachEventListeners() { if (this.screenshotBtn) { this.screenshotBtn.addEventListener('click', () => this.captureScreenshot()); } if (this.recordBtn) { this.recordBtn.addEventListener('click', () => this.startScreenRecording()); } if (this.submitFeedbackBtn) { this.submitFeedbackBtn.addEventListener('click', () => this.handleSubmit()); } if (this.settingsBtn) { this.settingsBtn.addEventListener('click', () => this.showSettings()); } if (this.saveSettingsBtn) { this.saveSettingsBtn.addEventListener('click', () => this.saveSettings()); } } async loadSettings() { const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken', 'jiraProject']); // Fill in settings if they exist if (settings.jiraDomain) this.jiraDomainInput.value = settings.jiraDomain; if (settings.jiraEmail) this.jiraEmailInput.value = settings.jiraEmail; if (settings.jiraToken) this.jiraTokenInput.value = settings.jiraToken; if (settings.jiraProject) this.jiraProjectInput.value = settings.jiraProject; // Show appropriate panel based on whether settings exist if (settings.jiraDomain && settings.jiraEmail && settings.jiraToken && settings.jiraProject) { this.showFeedbackForm(); } else { this.showSettings(); } } async saveSettings() { const domain = this.jiraDomainInput.value.trim(); const email = this.jiraEmailInput.value.trim(); const token = this.jiraTokenInput.value.trim(); const project = this.jiraProjectInput.value.trim(); if (!domain || !email || !token || !project) { this.showNotification('Please fill in all fields', 'error'); return; } try { // Validate credentials by making a test API call const isValid = await this.validateJiraCredentials(domain, email, token); if (!isValid) { this.showNotification('Invalid Jira credentials', 'error'); return; } // Save settings await chrome.storage.local.set({ jiraDomain: domain, jiraEmail: email, jiraToken: token, jiraProject: project }); this.showNotification('Settings saved successfully', 'success'); this.showFeedbackForm(); } catch (error) { console.error('Error saving settings:', error); this.showNotification('Error saving settings: ' + error.message, 'error'); } } async validateJiraCredentials(domain, email, token) { try { const response = await fetch(`https://${domain}/rest/api/2/myself`, { method: 'GET', headers: { 'Authorization': `Basic ${btoa(`${email}:${token}`)}`, 'Accept': 'application/json' } }); return response.ok; } catch (error) { console.error('Error validating Jira credentials:', error); return false; } } async handleSubmit() { try { const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken', 'jiraProject']); if (!settings.jiraDomain || !settings.jiraEmail || !settings.jiraToken || !settings.jiraProject) { this.showNotification('Please configure Jira settings first', 'error'); this.showSettings(); return; } const type = document.getElementById('feedback-type').value; const title = document.getElementById('feedback-title').value; const description = document.getElementById('feedback-description').value; if (!title || !description) { this.showNotification('Please fill in all required fields', 'error'); return; } // Map feedback type to standard Jira issue types let issueType; switch (type) { case 'Bug': issueType = 'Bug'; break; case 'Feature': issueType = 'Story'; break; case 'Improvement': issueType = 'Task'; break; default: issueType = 'Task'; } // Get current tab URL for context const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const contextInfo = ` Reported from: ${tab.url} Browser: ${navigator.userAgent} Timestamp: ${new Date().toISOString()} Description: ${description} `; const formData = { fields: { project: { key: settings.jiraProject }, summary: title, description: contextInfo, issuetype: { name: issueType }, labels: ["feedback-extension"] } }; console.log('Submitting to Jira with data:', JSON.stringify(formData, null, 2)); const response = await this.submitToJira(formData); // If we have a screenshot or video, attach it to the created issue if (response.key) { if (this.screenshot) { await this.attachScreenshotToIssue(response.key, this.screenshot); } if (this.recordedVideo) { await this.attachVideoToIssue(response.key, this.recordedVideo); } } if (response.key) { this.showNotification(`Issue ${response.key} created successfully!`, 'success'); document.getElementById('feedback-title').value = ''; document.getElementById('feedback-description').value = ''; this.attachmentPreview.innerHTML = ''; this.screenshot = null; this.recordedVideo = null; } } catch (error) { console.error('Full error details:', error); this.showNotification('Error submitting feedback: ' + error.message, 'error'); } } async submitToJira(formData) { const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']); try { // First, get available issue types for the project const metaResponse = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue/createmeta?projectKeys=${formData.fields.project.key}&expand=projects.issuetypes.fields`, { method: 'GET', headers: { 'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`, 'Accept': 'application/json' } }); const metaData = await metaResponse.json(); console.log('Project metadata:', metaData); if (!metaResponse.ok) { throw new Error('Failed to get project metadata'); } // Get available issue types const project = metaData.projects[0]; if (!project) { throw new Error('Project not found or no access'); } const availableTypes = project.issuetypes.map(type => type.name); console.log('Available issue types:', availableTypes); // If the desired issue type isn't available, fall back to Task if (!availableTypes.includes(formData.fields.issuetype.name)) { console.log(`Issue type ${formData.fields.issuetype.name} not available, falling back to Task`); formData.fields.issuetype.name = 'Task'; } // Create the issue const response = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue`, { method: 'POST', headers: { 'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`, 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); const responseData = await response.json(); console.log('Jira API Response:', responseData); if (!response.ok) { if (responseData.errors) { const errorMessage = Object.entries(responseData.errors) .map(([field, error]) => `${field}: ${error}`) .join(', '); throw new Error(errorMessage); } else if (responseData.errorMessages) { throw new Error(responseData.errorMessages.join(', ')); } else { throw new Error(`HTTP error! status: ${response.status}`); } } return responseData; } catch (error) { console.error('Error submitting to Jira:', error); console.error('Request data:', JSON.stringify(formData, null, 2)); throw error; } } async attachScreenshotToIssue(issueKey, screenshotDataUrl) { try { const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']); // Convert base64 data URL to blob const base64Data = screenshotDataUrl.replace(/^data:image\/png;base64,/, ''); const byteCharacters = atob(base64Data); const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += 512) { const slice = byteCharacters.slice(offset, offset + 512); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i++) { byteNumbers[i] = slice.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); byteArrays.push(byteArray); } const blob = new Blob(byteArrays, { type: 'image/png' }); // Create form data with the screenshot const formData = new FormData(); formData.append('file', blob, 'screenshot.png'); // Upload the attachment const response = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue/${issueKey}/attachments`, { method: 'POST', headers: { 'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`, 'X-Atlassian-Token': 'no-check' // Required for file uploads }, body: formData }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to attach screenshot: ${error}`); } console.log('Screenshot attached successfully'); } catch (error) { console.error('Error attaching screenshot:', error); throw new Error('Failed to attach screenshot: ' + error.message); } } async attachVideoToIssue(issueKey, videoBlob) { try { const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']); // Create form data with the video const formData = new FormData(); formData.append('file', videoBlob, 'screen-recording.webm'); // Upload the attachment const response = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue/${issueKey}/attachments`, { method: 'POST', headers: { 'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`, 'X-Atlassian-Token': 'no-check' // Required for file uploads }, body: formData }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to attach video: ${error}`); } console.log('Video attached successfully'); } catch (error) { console.error('Error attaching video:', error); throw new Error('Failed to attach video: ' + error.message); } } async captureScreenshot() { try { // Get current tab const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); console.log('Current tab:', tab); // Capture the visible tab console.log('Capturing screenshot...'); const screenshot = await chrome.tabs.captureVisibleTab(null, { format: 'png' }); console.log('Screenshot captured:', screenshot?.substring(0, 100) + '...'); // Verify screenshot data if (!screenshot || !screenshot.startsWith('data:image')) { throw new Error('Invalid screenshot data'); } // Store the screenshot data this.screenshot = screenshot; // Calculate window dimensions const maxWidth = Math.min(1200, window.screen.availWidth * 0.8); const maxHeight = Math.min(800, window.screen.availHeight * 0.8); const left = (window.screen.availWidth - maxWidth) / 2; const top = (window.screen.availHeight - maxHeight) / 2; // Open editor window console.log('Opening editor window...'); const editorUrl = chrome.runtime.getURL('editor.html'); this.editorWindow = window.open( editorUrl, 'screenshot-editor', `width=${maxWidth},height=${maxHeight},left=${left},top=${top},resizable=yes` ); if (!this.editorWindow) { throw new Error('Could not open editor window. Please allow popups for this extension.'); } // Send screenshot to background script chrome.runtime.sendMessage({ type: 'init-editor', screenshot: this.screenshot }, response => { console.log('Screenshot sent to background, response:', response); }); } catch (error) { console.error('Screenshot error:', error); this.showNotification('Error capturing screenshot: ' + error.message, 'error'); } } async startScreenRecording() { try { // Request screen capture const stream = await navigator.mediaDevices.getDisplayMedia({ video: { cursor: "always", frameRate: { ideal: 30 } }, audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 44100 } }); // Create MediaRecorder this.mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 3000000 // 3 Mbps }); // Set up recording handlers this.recordedChunks = []; this.mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { this.recordedChunks.push(event.data); } }; // Handle recording stop this.mediaRecorder.onstop = async () => { // Hide recording dialog this.recordingDialog.classList.add('hidden'); // Stop all tracks stream.getTracks().forEach(track => track.stop()); // Create blob from recorded chunks const blob = new Blob(this.recordedChunks, { type: 'video/webm' }); // Convert to MP4 using FFmpeg.js (to be implemented) try { const mp4Blob = await this.convertToMP4(blob); // Create object URL for preview const videoURL = URL.createObjectURL(mp4Blob); // Add video preview to attachment preview this.attachmentPreview.innerHTML = `
`; // Store the video blob for later submission this.recordedVideo = mp4Blob; // Show the main form again and restore window size document.body.classList.remove('recording'); this.feedbackForm.classList.remove('hidden'); window.resizeTo(400, 600); // Restore original size this.showNotification('Recording saved successfully!', 'success'); } catch (error) { console.error('Error converting video:', error); this.showNotification('Error converting video: ' + error.message, 'error'); } }; // Start recording this.mediaRecorder.start(1000); // Collect data every second // Hide the main form and show recording dialog document.body.classList.add('recording'); this.feedbackForm.classList.add('hidden'); this.recordingDialog.classList.remove('hidden'); // Set up stop recording button const stopButton = document.getElementById('stop-recording'); stopButton.onclick = () => { if (this.mediaRecorder && this.mediaRecorder.state === 'recording') { this.mediaRecorder.stop(); } }; // Minimize window to only show recording controls window.resizeTo(300, 100); } catch (error) { console.error('Screen recording error:', error); this.showNotification('Error starting screen recording: ' + error.message, 'error'); // Make sure main form is visible if there's an error document.body.classList.remove('recording'); this.feedbackForm.classList.remove('hidden'); } } async convertToMP4(webmBlob) { // For now, we'll just return the webm blob // In a future update, we can implement proper conversion to MP4 return webmBlob; } showSettings() { this.settingsPanel.classList.remove('hidden'); this.feedbackForm.classList.add('hidden'); } showFeedbackForm() { this.settingsPanel.classList.add('hidden'); this.feedbackForm.classList.remove('hidden'); } showNotification(message, type = 'info') { this.notification.textContent = message; this.notification.className = `notification ${type}`; this.notification.classList.remove('hidden'); setTimeout(() => { this.notification.classList.add('hidden'); }, 5000); } } // Initialize the extension new FeedbackExtension();