/** @odoo-module **/ // Fusion Helpdesk — submission dialog. Lets the user pick Bug or // Feature, fill in subject + description, paste an error code, attach // files, and capture a screenshot via the browser's getDisplayMedia // API. On submit, the payload is POSTed to /fusion_helpdesk/submit // which forwards it (XML-RPC) to a central Odoo Helpdesk. import { Component, useState } from "@odoo/owl"; import { Dialog } from "@web/core/dialog/dialog"; import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; import { _t } from "@web/core/l10n/translation"; const MAX_BYTES_PER_FILE = 10 * 1024 * 1024; // 10 MB hard cap per file export class FusionHelpdeskDialog extends Component { static template = "fusion_helpdesk.Dialog"; static components = { Dialog }; static props = { close: Function, }; setup() { this.notification = useService("notification"); this.state = useState({ kind: "bug", // 'bug' | 'feature' subject: "", description: "", errorCode: "", attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}] capturing: false, submitting: false, error: "", success: false, ticketId: null, ticketUrl: "", attached: 0, }); } get dialogTitle() { return this.state.kind === "bug" ? _t("Report a Bug") : _t("Request a Feature"); } setKind(kind) { this.state.kind = kind; } // ------------------------------------------------------------------ // File input → b64 async onFilesPicked(ev) { const files = Array.from(ev.target.files || []); for (const f of files) { if (f.size > MAX_BYTES_PER_FILE) { this.notification.add( _t("File '%s' is over 10 MB and was skipped.").replace("%s", f.name), { type: "warning" } ); continue; } try { const b64 = await this._fileToB64(f); this._addAttachment({ name: f.name, mimetype: f.type || "application/octet-stream", data_b64: b64, rawSize: f.size, }); } catch (err) { this.notification.add( _t("Could not read '%s'.").replace("%s", f.name), { type: "danger" } ); } } // Reset the input so picking the same file again re-fires onchange. ev.target.value = ""; } _fileToB64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result || ""; const idx = result.indexOf(","); resolve(idx >= 0 ? result.slice(idx + 1) : result); }; reader.onerror = reject; reader.readAsDataURL(file); }); } // ------------------------------------------------------------------ // Screenshot capture via getDisplayMedia async onTakeScreenshot() { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { this.notification.add( _t("Your browser doesn't support screen capture. Use Attach files instead."), { type: "warning" } ); return; } this.state.capturing = true; let stream; try { stream = await navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: "browser" }, audio: false, }); const blob = await this._streamToBlob(stream); const b64 = await this._blobToB64(blob); const ts = new Date().toISOString().replace(/[:.]/g, "-"); this._addAttachment({ name: `screenshot-${ts}.png`, mimetype: "image/png", data_b64: b64, rawSize: blob.size, }); } catch (err) { // User cancelled the picker — silently swallow. Other errors → notify. if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") { this.notification.add( _t("Screenshot failed: %s").replace("%s", err.message || err), { type: "danger" } ); } } finally { if (stream) { stream.getTracks().forEach((t) => t.stop()); } this.state.capturing = false; } } async _streamToBlob(stream) { const video = document.createElement("video"); video.srcObject = stream; await video.play(); // Give the browser one frame to settle the picker chrome. await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext("2d"); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); return await new Promise((resolve) => canvas.toBlob((b) => resolve(b), "image/png", 0.92) ); } _blobToB64(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result || ""; const idx = result.indexOf(","); resolve(idx >= 0 ? result.slice(idx + 1) : result); }; reader.onerror = reject; reader.readAsDataURL(blob); }); } _addAttachment({ name, mimetype, data_b64, rawSize }) { this.state.attachments.push({ name, mimetype, data_b64, sizeLabel: this._formatBytes(rawSize), iconClass: this._iconForMime(mimetype), }); } removeAttachment(idx) { this.state.attachments.splice(idx, 1); } _formatBytes(n) { if (!n) return ""; if (n < 1024) return n + " B"; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; return (n / (1024 * 1024)).toFixed(1) + " MB"; } _iconForMime(mt) { mt = (mt || "").toLowerCase(); if (mt.startsWith("image/")) return "fa fa-file-image-o"; if (mt.startsWith("video/")) return "fa fa-file-video-o"; if (mt.startsWith("audio/")) return "fa fa-file-audio-o"; if (mt.includes("pdf")) return "fa fa-file-pdf-o"; if (mt.includes("zip") || mt.includes("tar") || mt.includes("rar")) return "fa fa-file-archive-o"; if (mt.includes("text")) return "fa fa-file-text-o"; return "fa fa-file-o"; } // ------------------------------------------------------------------ // Submit async onSubmit() { if (this.state.submitting) return; const subject = (this.state.subject || "").trim(); if (!subject) { this.state.error = _t("Subject is required."); return; } this.state.error = ""; this.state.success = false; this.state.submitting = true; try { const payload = { kind: this.state.kind, subject, description: this.state.description || "", error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "", attachments: this.state.attachments.map((a) => ({ name: a.name, mimetype: a.mimetype, data_b64: a.data_b64, })), page_url: window.location.href, user_agent: navigator.userAgent, }; const res = await rpc("/fusion_helpdesk/submit", payload); if (!res.ok) { this.state.error = res.message || _t("Submission failed."); } else { this.state.success = true; this.state.ticketId = res.ticket_id; this.state.ticketUrl = res.ticket_url; this.state.attached = res.attached || 0; // Reset the editable fields so user can file another if they want. this.state.subject = ""; this.state.description = ""; this.state.errorCode = ""; this.state.attachments = []; } } catch (err) { this.state.error = (err && err.message) || _t("Network error."); } finally { this.state.submitting = false; } } }