diff --git a/TODO.md b/TODO.md index 8029732..f51e25b 100644 --- a/TODO.md +++ b/TODO.md @@ -125,7 +125,134 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K ## Core Feedback System -First implementation pass completed 2026-06-18. Remaining feedback work is mainly optional screenshot capture/storage, richer duplicate detection, date/plugin/area admin filters, issue creation, OKF correction conversion, and deeper attachment/DOM snapshot handling. Keep the detailed checklist below as the remaining reference for hardening and follow-up passes. +Current state on `experimental-feedback-system` as of 2026-06-18: the core feedback system is implemented locally and not yet pushed after the latest context-menu, review action, and screenshot attachment refinements. The detailed checklist below is retained for hardening, but this current-state block is the source of truth. + +### Implemented Locally + +- Core-level feedback storage in SQLite: + - `feedback_entries` + - `feedback_comments` + - `feedback_status_history` + - `feedback_view_state` +- Logged-in feedback submission through: + - persistent floating feedback button + - site-wide custom context menu + - `/api/feedback` +- `/feedback` under Community navigation for logged-in users. +- `/admin/feedback` under Admin navigation for admin review. +- Generic issue categories, severity values, scope types, and status labels. +- Feedback modal fields for summary, category, severity, scope, description, steps, expected behavior, actual behavior, page URL, page title, clicked-element metadata, and optional browser/viewport diagnostics. +- Element-targeted feedback from right-click context menu with safe metadata: + - selector/path + - tag + - visible text snippet + - aria label/title/role + - nearest label/heading + - page URL/title + - viewport size +- Sensitive form field values are not captured by the element metadata collector. +- Selected feedback target is highlighted while the modal is open and cleared when closed. +- User feedback center: + - public non-identifying feedback summary list + - “My feedback” section + - own-feedback detail view + - submitter comments/follow-up + - admin replies visible to submitter +- Per-user notification counters for solved, needs-context, and not-worked/not-planned outcomes. +- Notification counters reset/update when the user opens `/feedback`. +- Admin review page: + - full feedback details + - submitter identity + - target metadata + - optional diagnostics + - status history + - comments/replies + - private work notes + - filters for status, category, severity, scope, submitter, and needs-action + - filters for plugin/area text and submitted date range + - sorting by newest, oldest, severity, status, and last activity + - status/category/severity updates + - admin reply to submitter + - private admin work note + - request-more-context support through `needs_more_context` + - duplicate/rejected/not-planned/won't-fix/archive/delete statuses + - manual links to issue/correction references +- Admin review actions: + - `Finalize & Close` + - reopen closed feedback + - hard-delete feedback, comments, private notes, status history, and screenshots + - sensitive-data cleanup for screenshots, diagnostics, target metadata, and public admin replies + - feedback rows are collapsed by default +- Moderate submission rate limit. +- Validation against empty/too-short feedback. +- Server-side permission checks for submission, own-feedback details, submitter comments, and admin review. +- EJS escaping and sanitized server-side JSON/text handling for submitted feedback. +- AI Improvement Center remains separate; `wrong_tool_usage` classification already exists there. +- Custom context menu: + - Back + - Forward + - Copy + - Cut + - Paste + - Link to here + - Hard reload + - Feedback +- Context menu availability behavior: + - all actions remain visible + - unavailable actions are greyed out and non-interactive + - Back/Forward availability is history-aware where browser APIs allow + - Copy is available only for selected text or right-clicked images + - Cut is available only for selected text inside editable fields + - Paste is available only when an editable field is active and readable clipboard text exists + - Link to here, Hard reload, and Feedback remain available +- Context menu tooltips: + - show after one second of hover + - explain each action + - use fixed positioning + - clamp to the viewport so they do not overflow or expand the page +- Context menu keyboard behavior: + - arrow-key navigation + - Home/End navigation + - Enter/Space activation + - Tab focus wrapping while open +- Scope label/target field: + - displayed as a generated read-only “Detected target” field + - updates when “Feedback is about” changes + - is regenerated server-side and does not trust submitted free text +- Optional screenshot support: + - explicit opt-in only + - supports image upload + - supports pasted screenshots from the clipboard where browsers allow + - avoids browser tab/window picker capture so the browser prompt is not submitted + - shows an attached screenshot preview + - lets users remove the screenshot before submitting + - stores screenshots under `data/feedback/screenshots` + - exposes screenshots only to the submitter or admins through authenticated routes +- General protected attachments: + - supports up to three optional PNG/JPEG/WebP/PDF/text files per feedback item + - stores attachments under `data/feedback/attachments` + - exposes attachments only to the submitter or admins through authenticated routes + - supports admin cleanup/removal +- Public discussion on existing feedback: + - logged-in users can open public feedback detail without private diagnostics or attachments + - logged-in users can mark existing feedback as affecting them too + - logged-in users can add public context comments to existing feedback + +### Partially Implemented + +- Optional diagnostics exist for browser/user-agent, viewport, language, current URL, page title, selected scope, target metadata, screenshot attachment source, and opt-in sanitized page structure snapshots. +- Admin filters cover status/category/severity/scope/plugin-area/submitter/date/needs-action. Richer custom-scope filters are not implemented. +- OKF correction conversion is deferred for a later pass. Task tracking remains manual and outside the feedback feature. +- Duplicate handling includes lightweight similar-feedback suggestions from summary, description, scope, category, and page path; “also affected” counts; and public context comments, but not full duplicate merging. +- Feedback data is in SQLite under `data/app.db`, which is preserved by the update system through the existing protected `data/` path. Screenshot files are stored under `data/feedback/screenshots`, also covered by the protected `data/` path. + +### Remaining Work + +- Full scroll-page or automatic current-tab screenshot capture is not implemented. Current browser APIs require prompts for page capture, so the implemented path is explicit upload/paste. +- Full duplicate merge workflow and richer duplicate detection beyond lightweight summary/description/scope/category/page matching. +- Richer custom scope targeting filters. +- OKF correction conversion for documentation/terminology/knowledge feedback, deferred. +- Broader browser testing for image clipboard copy and clipboard-read availability because browser support and permissions vary. - Implement a general core feedback system available to logged-in users only. - Add a persistent feedback entry point somewhere logical in the UI. @@ -223,13 +350,12 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl ### Optional Screenshot Support -- Add optional browser-generated screenshot support. +- Add optional screenshot attachment support without browser tab/window picker prompts. - Screenshots must be opt-in by the submitting user. - Do not require screenshots for feedback submission. -- Support full-page screenshots where technically possible. -- Support cropped/section-only screenshots where technically possible. -- Temporarily hide feedback buttons, feedback modals, context menus, and feedback overlays while capturing screenshots. -- Restore hidden feedback UI immediately after capture. +- Support uploaded screenshot image files. +- Support pasted screenshots from the clipboard where browsers allow it. +- Consider full-page screenshots only if a safe DOM-rendering implementation or browser extension path is added later. - Store screenshots only when explicitly attached by the user. - Clearly warn users not to include sensitive information in screenshots. - Allow users to remove an attached screenshot before submitting. @@ -343,22 +469,8 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl - mark duplicate - archive - delete - - convert to TODO - - convert to issue - convert to OKF correction where relevant - - link to existing TODO/issue/correction - -### TODO and Issue Conversion - -- Allow admins to convert feedback into a TODO entry. -- Support injecting generated TODO entries into `TODO.md`. -- Preserve the existing TODO format when injecting new entries. -- Allow admins to edit the generated TODO text before saving. -- Add enough context to generated TODO items to be useful for later Codex work. -- Allow admins to convert feedback into a GitHub/Gitea issue if configured. -- Allow admins to link feedback to manually created TODOs or issues. -- Store links between feedback items and generated TODOs/issues. -- Do not automatically create TODOs or issues without admin confirmation. + - link to existing issue/correction ### OKF and AI Improvement Integration @@ -377,7 +489,7 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl - Encourage one feedback item per issue. - Add validation to prevent empty or extremely vague feedback. - Add optional duplicate detection based on similar title, scope, and page. -- If similar existing feedback exists, suggest adding a comment or upvote/support instead of creating a duplicate. +- If similar existing feedback exists, suggest adding a public comment or marking that it affects the user too instead of creating a duplicate. - Allow admins to delete or archive abusive/spam feedback. ### Data Storage and Preservation @@ -395,7 +507,7 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl - assigned_admin_id if used - status history - comments/replies - - linked TODOs/issues/corrections + - linked issues/corrections ### Privacy and Safety @@ -414,18 +526,25 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl - Logged-in users can submit targeted feedback from the custom right-click context menu. - Right-click context menu includes Back, Forward, Copy, Cut, Paste, Link to here, Hard reload, and Feedback. - Feedback modal can target the whole page, clicked element, current feature/page, plugin, broad system area, or custom scope. -- Users can optionally attach browser-generated screenshots while feedback UI is hidden during capture. +- Users can optionally attach screenshots by upload or clipboard paste. - `/feedback` appears under the Community navbar section for logged-in users. - `/feedback` shows general non-identifying feedback summaries and detailed feedback for the current user’s own submissions. - Users see per-user notification badges for solved, needs-context, and not-worked-on feedback. - Admins can review and manage feedback at `/admin/feedback`. -- Admins can reply, add work notes, request more context, change statuses, and convert feedback to TODOs/issues/corrections. +- Admins can reply, add work notes, request more context, change statuses, finalize and close, reopen closed feedback, and permanently delete feedback. - Feedback data is preserved across updates and included in protected user data. - AI reply feedback remains handled by the AI Improvement Center, with an added `wrong tool` classification. ## Done -- 2026-06-18: Added a core feedback system first pass on `experimental-feedback-system`: SQLite feedback entries/comments/status history/view state, logged-in `/feedback`, admin `/admin/feedback`, persistent feedback modal, site-wide custom context menu with element-targeted feedback, per-user notification badges, admin replies/work notes/status changes/TODO conversion, and AI Improvement Center `wrong_tool_usage` confirmed present. +- 2026-06-18: Removed feedback conversion actions from the experimental feedback system, added Finalize & Close/Reopen/Delete admin actions, made delete hard-remove feedback records/comments/notes/history/screenshots, kept admin feedback rows collapsed by default, and replaced browser tab capture screenshots with upload/clipboard-paste attachments. +- 2026-06-18: Fixed experimental feedback review buttons to use the stable save endpoint for Finalize & Close/Reopen and added a reusable Lumi `button-group` utility, including the `.field.button-group` specificity case, so review buttons align horizontally. +- 2026-06-18: Moved feedback screenshot guidance above Description, centered feedback screenshot/form action rows, and moved feedback modal sizing overrides into the later-loaded Lumi component layer so generic `.modal` styles no longer win. +- 2026-06-18: Added feedback “also affects me” records, user-facing similar-feedback suggestions before submit, admin plugin/area/date filters, sensitive-data cleanup controls, and keyboard navigation for the custom context menu; dropped external issue creation from the feedback roadmap and left OKF conversion deferred. +- 2026-06-18: Added protected general feedback attachments, protected attachment downloads, admin attachment cleanup/removal, viewer-safe public feedback details, and public context comments on existing feedback. +- 2026-06-18: Added opt-in sanitized page structure diagnostics for feedback, excluding sensitive form values and capping stored snapshot text server-side. +- 2026-06-18: Updated the experimental feedback context menu locally so all actions remain visible with disabled unavailable states, Back/Forward/Copy/Cut/Paste availability is context-aware, image/text copy and input-only cut/paste rules are enforced where browsers allow, and one-second viewport-clamped tooltips explain each action. +- 2026-06-18: Added a core feedback system first pass on `experimental-feedback-system`: SQLite feedback entries/comments/status history/view state, logged-in `/feedback`, admin `/admin/feedback`, persistent feedback modal, site-wide custom context menu with element-targeted feedback, per-user notification badges, admin replies/work notes/status changes, and AI Improvement Center `wrong_tool_usage` confirmed present. - 2026-06-17: Added Economy cleanup for fallback-name artifact users such as `SammyCat-2`, merging them into matching real platform users and making Economy stats tolerate missing/mid-migration tables; bumped Economy Framework to v0.2.9. - 2026-06-17: Fixed update metadata for renamed Economy plugins so legacy installed rows are folded into canonical `economy-*` plugin update rows instead of appearing as separate installs; bumped core to v0.1.8. - 2026-06-17: Renamed all remaining Economy internals from the old misspelled IDs/paths/tables to `economy-*`, added startup migration for legacy plugin rows, settings, command usage IDs, tables, uploads, asset paths, old URLs, and bumped core/plugin patch versions. diff --git a/scripts/verify-feedback-system.js b/scripts/verify-feedback-system.js index ab80dc4..756f39c 100644 --- a/scripts/verify-feedback-system.js +++ b/scripts/verify-feedback-system.js @@ -25,6 +25,9 @@ try { db.prepare( "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" ).run("admin-1", "AdminUser", now, now); + db.prepare( + "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" + ).run("user-2", "SecondUser", now, now); const entry = feedback.createFeedback({ summary: "Save button fails", @@ -47,22 +50,40 @@ try { diagnostics: { user_agent: "verification-agent", viewport: "1200x800", + screenshot_mode: "target", hidden: "must not persist" } - }, { id: "user-1" }); + }, { id: "user-1" }, { + screenshot: { + path: "feedback/screenshots/test-shot.png", + mime: "image/png", + size: 2048 + } + }); assert.equal(entry.status, "new"); + assert.equal(entry.scope_label, "Clicked element: Save settings"); assert.equal(entry.target_metadata.secret, undefined); assert.equal(entry.diagnostics.hidden, undefined); + assert.equal(entry.screenshot.mime, "image/png"); assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true); assert.equal(feedback.listMyFeedback("user-1").length, 1); + assert.equal(feedback.supportFeedback(entry.id, { id: "user-2" }), 1); + assert.equal(feedback.listPublicFeedback({ userId: "user-2" })[0].supported_by_me, true); feedback.addSubmitterComment(entry.id, "This also happens after a hard reload.", { id: "user-1" }); + feedback.addSubmitterComment(entry.id, "I see this too on the same settings page.", { id: "user-2" }); const withComment = feedback.getFeedbackForSubmitter(entry.id, "user-1"); - assert.equal(withComment.comments.length, 1); + assert.equal(withComment.comments.length, 2); assert.equal(withComment.comments[0].kind, "submitter_comment"); + assert.equal(withComment.comments[1].kind, "public_comment"); + const publicViewerEntry = feedback.getFeedbackForViewer(entry.id, "user-2"); + assert.equal(publicViewerEntry.is_mine, false); + assert.equal(publicViewerEntry.screenshot, null); + assert(publicViewerEntry.comments.some((comment) => comment.kind === "public_comment")); feedback.markFeedbackViewed("user-1"); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); feedback.adminUpdateFeedback(entry.id, { status: "needs_more_context", category: "bug", @@ -79,11 +100,79 @@ try { const submitterEntry = feedback.getFeedbackForSubmitter(entry.id, "user-1"); assert.equal(submitterEntry.comments.some((comment) => comment.kind === "work_note"), false); - const todoPath = path.join(sandbox, "TODO.md"); - fs.writeFileSync(todoPath, "# TODO\n\n## Done\n"); - feedback.convertFeedbackToTodo(entry.id, "Fix feedback verification issue.", { id: "admin-1" }, todoPath); - assert(fs.readFileSync(todoPath, "utf8").includes("## Feedback-Derived TODOs")); - assert(fs.readFileSync(todoPath, "utf8").includes("Fix feedback verification issue.")); + feedback.adminUpdateFeedback(entry.id, { + status: "closed", + category: "bug", + severity: "urgent", + status_note: "Finalized after verification." + }, { id: "admin-1" }); + assert.equal(feedback.getFeedbackForAdmin(entry.id).status, "closed"); + feedback.adminUpdateFeedback(entry.id, { + status: "reviewed", + category: "bug", + severity: "urgent", + status_note: "Reopened after verification." + }, { id: "admin-1" }); + assert.equal(feedback.getFeedbackForAdmin(entry.id).status, "reviewed"); + assert(feedback.findSimilarFeedback({ + summary: "Settings save button does nothing", + category: "bug", + scope_type: "element", + current_url: "http://localhost/admin/settings" + }, { userId: "user-2" }).some((match) => match.id === entry.id)); + assert(feedback.findSimilarFeedback({ + summary: "Controls are weird", + description: "Clicking the save button does not apply changes after editing settings.", + category: "bug", + scope_type: "element", + current_url: "http://localhost/admin/settings" + }, { userId: "user-2" }).some((match) => match.id === entry.id)); + + const cleanupEntry = feedback.createFeedback({ + summary: "Sensitive screenshot cleanup", + category: "bug", + severity: "minor", + scope_type: "page", + description: "This report exists to verify cleanup behavior.", + current_url: "http://localhost/admin/feedback", + page_title: "Feedback review", + diagnostics: { user_agent: "sensitive-agent" } + }, { id: "user-1" }, { + screenshot: { + path: "feedback/screenshots/cleanup-shot.png", + mime: "image/png", + size: 1024 + }, + attachments: [{ + path: "feedback/attachments/cleanup-note.txt", + mime: "text/plain", + size: 128, + original_name: "cleanup-note.txt" + }] + }); + assert.equal(feedback.getFeedbackForAdmin(cleanupEntry.id).attachments.length, 1); + let cleanedScreenshot = ""; + let cleanedAttachment = ""; + feedback.cleanupFeedback(cleanupEntry.id, { + clear_screenshot: "1", + clear_attachments: "1", + clear_diagnostics: "1", + clear_target_metadata: "1" + }, { id: "admin-1" }, { + deleteScreenshot(relativePath) { + cleanedScreenshot = relativePath; + }, + deleteAttachment(relativePath) { + cleanedAttachment = relativePath; + } + }); + const cleaned = feedback.getFeedbackForAdmin(cleanupEntry.id); + assert.equal(cleaned.screenshot, null); + assert.deepEqual(cleaned.diagnostics, {}); + assert.deepEqual(cleaned.target_metadata, {}); + assert.equal(cleaned.attachments.length, 0); + assert.equal(cleanedScreenshot, "feedback/screenshots/cleanup-shot.png"); + assert.equal(cleanedAttachment, "feedback/attachments/cleanup-note.txt"); const feedbackView = path.join(root, "src", "web", "views", "feedback.ejs"); const adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs"); @@ -113,6 +202,17 @@ try { }, { filename: feedbackView }); assert(userRendered.includes("My feedback")); assert(userRendered.includes("Admin reply")); + assert(userRendered.includes("Open screenshot")); + assert(userRendered.includes("Community comment")); + const supporterRendered = ejs.render(fs.readFileSync(feedbackView, "utf8"), { + ...commonLocals, + user: { id: "user-2", username: "SecondUser" }, + title: "Feedback", + feedbackList: feedback.listPublicFeedback({ userId: "user-2" }), + myFeedback: feedback.listMyFeedback("user-2"), + selectedFeedback: null + }, { filename: feedbackView }); + assert(supporterRendered.includes("Marked as affecting you too.")); const adminRendered = ejs.render(fs.readFileSync(adminView, "utf8"), { ...commonLocals, @@ -120,10 +220,28 @@ try { userInitial: "A", title: "Feedback review", feedbackItems: feedback.listFeedbackForAdmin({}), - filters: { status: "", category: "", severity: "", scope: "", submitter: "", needs_action: "", sort: "last_activity" } + filters: { status: "", category: "", severity: "", scope: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" } }, { filename: adminView }); assert(adminRendered.includes("Feedback queue")); - assert(adminRendered.includes("Convert to TODO")); + assert(!adminRendered.includes("Convert useful reports")); + assert(adminRendered.includes("Finalize & Close")); + assert(adminRendered.includes("Delete")); + assert(adminRendered.includes("Open attached screenshot")); + assert(adminRendered.includes("Sensitive data cleanup")); + assert(adminRendered.includes("Remove attachments")); + assert(adminRendered.includes("Plugin/area")); + assert(adminRendered.includes("1 also affected")); + + let deletedScreenshot = ""; + feedback.deleteFeedback(entry.id, { + deleteScreenshot(relativePath) { + deletedScreenshot = relativePath; + } + }); + assert.equal(feedback.getFeedbackForAdmin(entry.id), null); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM feedback_comments WHERE feedback_id = ?").get(entry.id).count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM feedback_status_history WHERE feedback_id = ?").get(entry.id).count, 0); + assert.equal(deletedScreenshot, "feedback/screenshots/test-shot.png"); console.log("Core feedback system verification passed."); } finally { diff --git a/src/services/db.js b/src/services/db.js index fc2decf..dc8b0b1 100644 --- a/src/services/db.js +++ b/src/services/db.js @@ -166,6 +166,9 @@ function migrate() { expected_behavior TEXT, actual_behavior TEXT, diagnostics_json TEXT NOT NULL DEFAULT '{}', + screenshot_path TEXT, + screenshot_mime TEXT, + screenshot_size INTEGER, status TEXT NOT NULL DEFAULT 'new', admin_reply TEXT, assigned_admin_id TEXT, @@ -209,6 +212,28 @@ function migrate() { user_id TEXT PRIMARY KEY, last_seen_at INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS feedback_support ( + feedback_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (feedback_id, user_id) + ); + + CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at); + + CREATE TABLE IF NOT EXISTS feedback_attachments ( + id TEXT PRIMARY KEY, + feedback_id TEXT NOT NULL, + storage_path TEXT NOT NULL, + original_name TEXT, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + kind TEXT NOT NULL DEFAULT 'attachment', + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at); `); const columns = db @@ -271,6 +296,20 @@ function migrate() { db.exec("ALTER TABLE user_profiles ADD COLUMN username_updated_at INTEGER"); } + const feedbackColumns = db + .prepare("PRAGMA table_info(feedback_entries)") + .all() + .map((column) => column.name); + if (!feedbackColumns.includes("screenshot_path")) { + db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_path TEXT"); + } + if (!feedbackColumns.includes("screenshot_mime")) { + db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_mime TEXT"); + } + if (!feedbackColumns.includes("screenshot_size")) { + db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER"); + } + migrateLegacyUsers(); } diff --git a/src/services/feedback.js b/src/services/feedback.js index f38e41b..e2cd465 100644 --- a/src/services/feedback.js +++ b/src/services/feedback.js @@ -1,5 +1,3 @@ -const fs = require("fs"); -const path = require("path"); const crypto = require("crypto"); const { db } = require("./db"); @@ -49,12 +47,13 @@ const FEEDBACK_STATUSES = Object.freeze([ "rejected", "not_planned", "wont_fix", + "closed", "archived", "deleted" ]); const USER_VISIBLE_STATUSES = new Set(FEEDBACK_STATUSES.filter((status) => status !== "deleted")); -const SOLVED_STATUSES = new Set(["fixed", "solved"]); +const SOLVED_STATUSES = new Set(["fixed", "solved", "closed"]); const NEEDS_CONTEXT_STATUSES = new Set(["needs_more_context"]); const NOT_WORKING_STATUSES = new Set(["duplicate", "rejected", "not_planned", "wont_fix"]); const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 }; @@ -104,6 +103,7 @@ const STATUS_LABELS = Object.freeze({ rejected: "Rejected", not_planned: "Not planned", wont_fix: "Won't fix", + closed: "Closed", archived: "Archived", deleted: "Deleted" }); @@ -121,23 +121,26 @@ const STATUS_HELP = Object.freeze({ rejected: "Rejected after review.", not_planned: "Not planned for implementation.", wont_fix: "Reviewed, but will not be changed.", + closed: "Finalized and closed by an administrator.", archived: "Archived for record keeping.", deleted: "Deleted by an administrator." }); -function createFeedback(input, actor) { +function createFeedback(input, actor, options = {}) { if (!actor?.id) { throw new Error("Feedback requires a logged-in user."); } enforceRateLimit(actor.id); const now = Date.now(); const entry = normalizeFeedbackInput(input); + const screenshot = normalizeScreenshot(options.screenshot); + const attachments = normalizeAttachments(options.attachments); const id = crypto.randomUUID(); db.transaction(() => { db.prepare( "INSERT INTO feedback_entries " + - "(id, submitter_id, summary, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, description, steps_to_reproduce, expected_behavior, actual_behavior, diagnostics_json, status, created_at, updated_at, last_activity_at) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)" + "(id, submitter_id, summary, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, description, steps_to_reproduce, expected_behavior, actual_behavior, diagnostics_json, screenshot_path, screenshot_mime, screenshot_size, status, created_at, updated_at, last_activity_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)" ).run( id, actor.id, @@ -154,10 +157,27 @@ function createFeedback(input, actor) { entry.expected_behavior, entry.actual_behavior, JSON.stringify(entry.diagnostics), + screenshot.path, + screenshot.mime, + screenshot.size, now, now, now ); + for (const attachment of attachments) { + db.prepare( + "INSERT INTO feedback_attachments (id, feedback_id, storage_path, original_name, mime, size, kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ).run( + crypto.randomUUID(), + id, + attachment.path, + attachment.original_name, + attachment.mime, + attachment.size, + attachment.kind, + now + ); + } addStatusHistory(id, "new", actor.id, "Submitted", now); })(); return getFeedbackForAdmin(id); @@ -170,13 +190,58 @@ function listPublicFeedback({ userId, limit = 100 } = {}) { "FROM feedback_entries WHERE deleted_at IS NULL AND status != 'deleted' " + "ORDER BY last_activity_at DESC LIMIT ?" ) - .all(limit) - .map((row) => ({ + .all(limit); + const support = supportSummary(rows.map((row) => row.id), userId); + return rows.map((row) => ({ + ...decorateLabels(row), + support_count: support.counts.get(row.id) || 0, + supported_by_me: support.mine.has(row.id), + is_mine: userId ? row.submitter_id === userId : false, + submitter_id: undefined + })); +} + +function findSimilarFeedback(input = {}, options = {}) { + const summary = cleanText(input.summary, 140); + const description = cleanText(input.description, 6000); + const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : ""; + const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : ""; + const currentUrl = cleanUrl(input.current_url); + const pagePath = pagePathKey(currentUrl); + if (summary.length < 6 && description.length < 12 && !pagePath) return []; + const rows = db + .prepare( + "SELECT id, submitter_id, summary, description, category, severity, scope_type, scope_label, current_url, page_title, status, created_at, updated_at, last_activity_at " + + "FROM feedback_entries WHERE deleted_at IS NULL AND status NOT IN ('deleted', 'closed', 'solved', 'fixed', 'archived') " + + "ORDER BY last_activity_at DESC LIMIT 150" + ) + .all(); + const queryTokens = tokenSet(`${summary} ${description}`); + const matches = rows + .map((row) => { + const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.scope_label || ""} ${row.page_title || ""}`); + const samePath = pagePath && pagePath === pagePathKey(row.current_url); + const score = + jaccardScore(queryTokens, rowTokens) + + (scopeType && row.scope_type === scopeType ? 0.25 : 0) + + (category && row.category === category ? 0.15 : 0) + + (samePath ? 0.35 : 0); + return { row, score }; + }) + .filter(({ score }) => score >= 0.32) + .sort((a, b) => b.score - a.score) + .slice(0, Math.min(Number(options.limit || 5), 10)) + .map(({ row, score }) => ({ ...decorateLabels(row), - is_mine: userId ? row.submitter_id === userId : false, + match_score: Math.round(score * 100) / 100, submitter_id: undefined })); - return rows; + const support = supportSummary(matches.map((row) => row.id), options.userId); + return matches.map((row) => ({ + ...row, + support_count: support.counts.get(row.id) || 0, + supported_by_me: support.mine.has(row.id) + })); } function listMyFeedback(userId) { @@ -197,9 +262,30 @@ function getFeedbackForSubmitter(id, userId) { return hydrateFeedback(row, { admin: false }); } +function getFeedbackForViewer(id, userId) { + const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id); + if (!row || row.deleted_at || row.status === "deleted") { + return null; + } + const entry = hydrateFeedback(row, { admin: false }); + entry.is_mine = Boolean(userId && row.submitter_id === userId); + const support = supportSummary([row.id], userId); + entry.support_count = support.counts.get(row.id) || 0; + entry.supported_by_me = support.mine.has(row.id); + if (!entry.is_mine) { + entry.screenshot = null; + entry.attachments = []; + } + return entry; +} + function getFeedbackForAdmin(id) { const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id); - return row ? hydrateFeedback(row, { admin: true }) : null; + if (!row) return null; + const entry = hydrateFeedback(row, { admin: true }); + const support = supportSummary([row.id]); + entry.support_count = support.counts.get(row.id) || 0; + return entry; } function listFeedbackForAdmin(filters = {}) { @@ -224,6 +310,22 @@ function listFeedbackForAdmin(filters = {}) { where.push("feedback_entries.scope_type = ?"); params.push(filters.scope); } + if (filters.area) { + where.push( + "(lower(feedback_entries.scope_label) LIKE lower(?) OR lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.page_title) LIKE lower(?))" + ); + params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`); + } + const from = parseDateBoundary(filters.date_from, "start"); + if (from) { + where.push("feedback_entries.created_at >= ?"); + params.push(from); + } + const to = parseDateBoundary(filters.date_to, "end"); + if (to) { + where.push("feedback_entries.created_at <= ?"); + params.push(to); + } if (filters.submitter) { where.push( "(feedback_entries.submitter_id = ? OR lower(user_profiles.internal_username) LIKE lower(?))" @@ -240,34 +342,54 @@ function listFeedbackForAdmin(filters = {}) { last_activity: "feedback_entries.last_activity_at DESC", newest: "feedback_entries.created_at DESC" }[filters.sort || "last_activity"]; - return db + const rows = db .prepare( "SELECT feedback_entries.*, user_profiles.internal_username AS submitter_name " + "FROM feedback_entries LEFT JOIN user_profiles ON user_profiles.id = feedback_entries.submitter_id " + `WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT 250` ) - .all(...params) - .map((row) => hydrateFeedback(row, { admin: true })); + .all(...params); + const support = supportSummary(rows.map((row) => row.id)); + return rows.map((row) => { + const entry = hydrateFeedback(row, { admin: true }); + entry.support_count = support.counts.get(row.id) || 0; + return entry; + }); } function addSubmitterComment(id, body, actor) { - const entry = getFeedbackForSubmitter(id, actor?.id); - if (!entry) { + const row = db.prepare("SELECT id, submitter_id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id); + if (!row || row.deleted_at || row.status === "deleted" || !actor?.id) { throw new Error("Feedback item was not found."); } const comment = cleanText(body, 4000); if (comment.length < 3) { throw new Error("Comment is too short."); } + const kind = row.submitter_id === actor.id ? "submitter_comment" : "public_comment"; const now = Date.now(); db.transaction(() => { db.prepare( - "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'submitter_comment', ?, 1, ?)" - ).run(id, actor.id, comment, now); + "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, ?, ?, 1, ?)" + ).run(id, actor.id, kind, comment, now); touchFeedback(id, now); })(); } +function supportFeedback(id, actor) { + if (!actor?.id) { + throw new Error("Support requires a logged-in user."); + } + const row = db.prepare("SELECT id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id); + if (!row || row.deleted_at || row.status === "deleted") { + throw new Error("Feedback item was not found."); + } + db.prepare( + "INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)" + ).run(id, actor.id, Date.now()); + return supportSummary([id], actor.id).counts.get(id) || 0; +} + function adminUpdateFeedback(id, input, actor) { const current = getFeedbackForAdmin(id); if (!current) { @@ -314,41 +436,97 @@ function adminUpdateFeedback(id, input, actor) { return getFeedbackForAdmin(id); } -function convertFeedbackToTodo(id, body, actor, todoPath = path.join(__dirname, "..", "..", "TODO.md")) { - const entry = getFeedbackForAdmin(id); - if (!entry) { +function deleteFeedback(id, options = {}) { + const current = db.prepare("SELECT id, screenshot_path FROM feedback_entries WHERE id = ?").get(id); + if (!current) { throw new Error("Feedback item was not found."); } - const text = cleanText(body, 4000) || defaultTodoText(entry); - if (!text) { - throw new Error("TODO text is required."); + const attachments = attachmentsFor(id); + db.transaction(() => { + db.prepare("DELETE FROM feedback_comments WHERE feedback_id = ?").run(id); + db.prepare("DELETE FROM feedback_status_history WHERE feedback_id = ?").run(id); + db.prepare("DELETE FROM feedback_support WHERE feedback_id = ?").run(id); + db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(id); + db.prepare("DELETE FROM feedback_entries WHERE id = ?").run(id); + })(); + if (current.screenshot_path && typeof options.deleteScreenshot === "function") { + options.deleteScreenshot(current.screenshot_path); } - const current = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, "utf8") : "# TODO\n\n## Done\n"; - const section = "## Feedback-Derived TODOs"; - const bullet = text - .split(/\r?\n/) - .map((line, index) => (index === 0 ? `- ${line}` : ` ${line}`)) - .join("\n"); - let next = current; - if (!current.includes(section)) { - next = current.replace(/\n## Done\b/, `\n${section}\n\n${bullet}\n\n## Done`); - if (next === current) { - next = `${current.replace(/\s*$/, "")}\n\n${section}\n\n${bullet}\n`; + if (typeof options.deleteAttachment === "function") { + attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path)); + } + return current; +} + +function cleanupFeedback(id, input = {}, actor, options = {}) { + const current = getFeedbackForAdmin(id); + if (!current) { + throw new Error("Feedback item was not found."); + } + const changes = []; + const sets = []; + if (input.clear_screenshot === "1" && current.screenshot?.path) { + sets.push("screenshot_path = NULL", "screenshot_mime = NULL", "screenshot_size = NULL"); + changes.push("screenshot"); + } + if (input.clear_diagnostics === "1") { + sets.push("diagnostics_json = '{}'"); + changes.push("diagnostics"); + } + if (input.clear_target_metadata === "1") { + sets.push("target_metadata_json = '{}'"); + changes.push("target metadata"); + } + if (input.clear_admin_reply === "1") { + sets.push("admin_reply = NULL"); + changes.push("admin reply"); + } + const clearAttachments = input.clear_attachments === "1"; + const attachments = clearAttachments ? attachmentsFor(id) : []; + if (clearAttachments && attachments.length) { + changes.push("attachments"); + } + if (!sets.length) { + if (!clearAttachments || !attachments.length) { + throw new Error("Choose at least one feedback data field to clean."); } - } else { - next = current.replace(section, `${section}\n\n${bullet}`); } - fs.writeFileSync(todoPath, next); + if (!sets.length && clearAttachments && attachments.length) { + sets.push("updated_at = updated_at"); + } + if (!changes.length) { + throw new Error("Choose at least one feedback data field to clean."); + } const now = Date.now(); db.transaction(() => { db.prepare( - "UPDATE feedback_entries SET linked_todo = ?, updated_at = ?, last_activity_at = ? WHERE id = ?" - ).run(text, now, now, id); + `UPDATE feedback_entries SET ${sets.join(", ")}, updated_at = ?, last_activity_at = ? WHERE id = ?` + ).run(now, now, id); + if (clearAttachments) { + db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(id); + } db.prepare( "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)" - ).run(id, actor.id, `Converted to TODO: ${text}`, now); + ).run(id, actor?.id || null, `Cleaned sensitive feedback data: ${changes.join(", ")}.`, now); })(); - return text; + if (input.clear_screenshot === "1" && current.screenshot?.path && typeof options.deleteScreenshot === "function") { + options.deleteScreenshot(current.screenshot.path); + } + if (clearAttachments && typeof options.deleteAttachment === "function") { + attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path)); + } + return getFeedbackForAdmin(id); +} + +function getFeedbackAttachment(feedbackId, attachmentId, userId, isAdmin = false) { + const row = db.prepare("SELECT id, submitter_id, deleted_at, status FROM feedback_entries WHERE id = ?").get(feedbackId); + if (!row || row.deleted_at || row.status === "deleted") { + return null; + } + if (!isAdmin && row.submitter_id !== userId) { + return null; + } + return db.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? AND id = ?").get(feedbackId, attachmentId) || null; } function markFeedbackViewed(userId) { @@ -407,15 +585,17 @@ function normalizeFeedbackInput(input = {}) { const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "other"; const severity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : "minor"; const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "page"; + const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata); + const pageTitle = cleanText(input.page_title, 240); return { summary, category, severity, scope_type: scopeType, - scope_label: cleanText(input.scope_label, 240), - target_metadata: sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata), + scope_label: deriveScopeLabel(scopeType, targetMetadata, pageTitle, input.current_url), + target_metadata: targetMetadata, current_url: cleanUrl(input.current_url), - page_title: cleanText(input.page_title, 240), + page_title: pageTitle, description, steps_to_reproduce: cleanText(input.steps_to_reproduce, 4000), expected_behavior: cleanText(input.expected_behavior, 4000), @@ -424,6 +604,102 @@ function normalizeFeedbackInput(input = {}) { }; } +function deriveScopeLabel(scopeType, metadata, pageTitle, currentUrl) { + const pageLabel = pageTitle || pagePathLabel(currentUrl) || "this page"; + const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector; + if (scopeType === "element") { + return cleanText(elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`, 240); + } + if (scopeType === "feature") return cleanText(`Feature/page: ${pageLabel}`, 240); + if (scopeType === "plugin") { + const plugin = pluginLabelFromUrl(currentUrl); + return cleanText(plugin ? `Plugin: ${plugin}` : `Plugin-related feedback on ${pageLabel}`, 240); + } + if (scopeType === "system") return cleanText(`System area: ${pageLabel}`, 240); + if (scopeType === "other") return cleanText(`Other feedback on ${pageLabel}`, 240); + return cleanText(`Whole page: ${pageLabel}`, 240); +} + +function pluginLabelFromUrl(value) { + const raw = cleanText(value, 1000); + try { + const url = new URL(raw, "http://localhost"); + const match = url.pathname.match(/\/plugins\/([^/]+)/); + return match ? match[1].replace(/[_-]+/g, " ") : ""; + } catch { + return ""; + } +} + +function pagePathLabel(value) { + const raw = cleanText(value, 1000); + try { + const url = new URL(raw, "http://localhost"); + return url.pathname === "/" ? "Home" : url.pathname.replace(/^\/+/, "").replace(/[/_-]+/g, " "); + } catch { + return ""; + } +} + +function pagePathKey(value) { + const raw = cleanText(value, 1000); + try { + const url = new URL(raw, "http://localhost"); + return url.pathname.replace(/\/+$/, "") || "/"; + } catch { + return ""; + } +} + +function tokenSet(value) { + return new Set( + cleanText(value, 500) + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length >= 3) + ); +} + +function jaccardScore(a, b) { + if (!a.size || !b.size) return 0; + let intersection = 0; + for (const token of a) { + if (b.has(token)) intersection += 1; + } + return intersection / new Set([...a, ...b]).size; +} + +function supportSummary(ids, userId) { + const cleanIds = [...new Set((ids || []).filter(Boolean))]; + const counts = new Map(); + const mine = new Set(); + if (!cleanIds.length) { + return { counts, mine }; + } + const placeholders = cleanIds.map(() => "?").join(","); + db.prepare( + `SELECT feedback_id, COUNT(*) AS count FROM feedback_support WHERE feedback_id IN (${placeholders}) GROUP BY feedback_id` + ) + .all(...cleanIds) + .forEach((row) => counts.set(row.feedback_id, row.count)); + if (userId) { + db.prepare( + `SELECT feedback_id FROM feedback_support WHERE user_id = ? AND feedback_id IN (${placeholders})` + ) + .all(userId, ...cleanIds) + .forEach((row) => mine.add(row.feedback_id)); + } + return { counts, mine }; +} + +function parseDateBoundary(value, edge) { + const raw = cleanText(value, 40); + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null; + const date = new Date(`${raw}T${edge === "end" ? "23:59:59.999" : "00:00:00.000"}`); + const time = date.getTime(); + return Number.isFinite(time) ? time : null; +} + function enforceRateLimit(userId) { const cutoff = Date.now() - RATE_LIMIT.windowMs; const count = db @@ -441,6 +717,14 @@ function hydrateFeedback(row, { admin }) { ...decorateLabels(row), target_metadata: parseJson(row.target_metadata_json, {}), diagnostics: admin ? parseJson(row.diagnostics_json, {}) : {}, + screenshot: row.screenshot_path + ? { + path: row.screenshot_path, + mime: row.screenshot_mime || "image/png", + size: row.screenshot_size || 0 + } + : null, + attachments: attachmentsFor(row.id), comments: commentsFor(row.id, admin), history: statusHistoryFor(row.id) }; @@ -453,6 +737,21 @@ function hydrateFeedback(row, { admin }) { return parsed; } +function attachmentsFor(feedbackId) { + return db + .prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? ORDER BY created_at ASC") + .all(feedbackId) + .map((row) => ({ + id: row.id, + storage_path: row.storage_path, + original_name: row.original_name || "attachment", + mime: row.mime, + size: row.size, + kind: row.kind, + created_at: row.created_at + })); +} + function commentsFor(feedbackId, admin) { return db .prepare( @@ -502,11 +801,6 @@ function decorateLabels(row) { }; } -function defaultTodoText(entry) { - const url = entry.current_url ? ` (${entry.current_url})` : ""; - return `[Feedback ${entry.id.slice(0, 8)}] ${entry.summary}${url}: ${entry.description.slice(0, 300)}`; -} - function sanitizeTargetMetadata(value) { return { selector: cleanText(value.selector, 500), @@ -527,10 +821,57 @@ function sanitizeDiagnostics(value) { return { user_agent: cleanText(value.user_agent, 500), viewport: cleanText(value.viewport, 80), - language: cleanText(value.language, 80) + language: cleanText(value.language, 80), + dom_snapshot: cleanText(value.dom_snapshot, 6000), + screenshot_mode: cleanText(value.screenshot_mode, 40), + screenshot_source: cleanText(value.screenshot_source, 40) }; } +function normalizeScreenshot(value = {}) { + const pathValue = cleanText(value.path, 500); + const mime = cleanText(value.mime, 80); + const size = Number(value.size || 0); + if (!pathValue) { + return { path: null, mime: null, size: null }; + } + if (!/^feedback\/screenshots\/[a-zA-Z0-9_.-]+$/.test(pathValue)) { + throw new Error("Invalid screenshot storage path."); + } + if (!["image/png", "image/jpeg", "image/webp"].includes(mime)) { + throw new Error("Unsupported screenshot type."); + } + if (!Number.isFinite(size) || size <= 0 || size > 8 * 1024 * 1024) { + throw new Error("Screenshot file size is invalid."); + } + return { path: pathValue, mime, size }; +} + +function normalizeAttachments(values = []) { + const entries = Array.isArray(values) ? values : []; + return entries.map((value) => { + const pathValue = cleanText(value.path, 500); + const mime = cleanText(value.mime, 80); + const size = Number(value.size || 0); + if (!/^feedback\/attachments\/[a-zA-Z0-9_.-]+$/.test(pathValue)) { + throw new Error("Invalid attachment storage path."); + } + if (!["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(mime)) { + throw new Error("Unsupported attachment type."); + } + if (!Number.isFinite(size) || size <= 0 || size > 8 * 1024 * 1024) { + throw new Error("Attachment file size is invalid."); + } + return { + path: pathValue, + mime, + size, + original_name: cleanText(value.original_name, 240) || "attachment", + kind: "attachment" + }; + }); +} + function sanitizeJsonObject(value, sanitizer) { const object = typeof value === "object" && value && !Array.isArray(value) ? value : {}; return removeEmptyFields(sanitizer(object)); @@ -568,6 +909,7 @@ function cleanUrl(value) { function commentKindLabel(kind) { return { submitter_comment: "Submitter comment", + public_comment: "Community comment", admin_reply: "Admin reply", work_note: "Private work note" }[kind] || kind; @@ -591,15 +933,20 @@ module.exports = { FEEDBACK_STATUSES, USER_VISIBLE_STATUSES, adminUpdateFeedback, - convertFeedbackToTodo, + cleanupFeedback, + deleteFeedback, createFeedback, + findSimilarFeedback, feedbackOptions, getFeedbackForAdmin, + getFeedbackAttachment, getFeedbackForSubmitter, + getFeedbackForViewer, listFeedbackForAdmin, listMyFeedback, listPublicFeedback, markFeedbackViewed, notificationSummary, + supportFeedback, addSubmitterComment }; diff --git a/src/web/public/app.js b/src/web/public/app.js index 03fd27f..cd4b410 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -951,6 +951,17 @@ const contextMenu = document.querySelector("[data-lumi-context-menu]"); let contextTarget = null; let highlightedFeedbackTarget = null; + let contextClipboardText = ""; + let contextTooltipTimer = null; + let contextTooltip = null; + let activeFeedbackTarget = null; + let activeFeedbackMetadata = {}; + let attachedScreenshotBlob = null; + let attachedScreenshotUrl = ""; + let attachedScreenshotSource = ""; + let attachedScreenshotName = "feedback-screenshot.png"; + let feedbackSimilarTimer = null; + let feedbackSimilarController = null; const parseJson = (value, fallback = {}) => { try { @@ -980,6 +991,35 @@ setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, "page"); } + const scopeLabelForValue = (value) => { + const option = (feedbackOptions.scopes || []).find((entry) => entry.value === value); + return option?.label || value; + }; + + const screenshotShortcutHint = () => { + const platform = `${navigator.userAgentData?.platform || navigator.platform || ""}`.toLowerCase(); + if (platform.includes("mac")) { + return "Press Shift + Command + 4 to take a selected-area screenshot"; + } + if (platform.includes("linux")) { + return "Press Print Screen or Shift + Print Screen to take a screenshot"; + } + if (platform.includes("iphone") || platform.includes("ipad")) { + return "Use your device screenshot buttons, then choose the saved image"; + } + if (platform.includes("android")) { + return "Use your device screenshot buttons, then choose the saved image"; + } + return "Press Win + Shift + S to take a snippet screenshot"; + }; + + if (feedbackForm) { + const keybindHint = feedbackForm.querySelector("[data-feedback-screenshot-keybind]"); + if (keybindHint) { + keybindHint.textContent = screenshotShortcutHint(); + } + } + const sensitiveSelector = [ "input[type='password']", "input[type='hidden']", @@ -1049,6 +1089,28 @@ return safeText(h1?.innerText || document.title, 180); }; + const generatedScopeLabel = (scope, metadata = {}) => { + const pageLabel = currentPageLabel() || "this page"; + const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector; + if (scope === "element") return elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`; + if (scope === "feature") return `Feature/page: ${pageLabel}`; + if (scope === "plugin") { + const pluginMatch = window.location.pathname.match(/\/plugins\/([^/]+)/); + return pluginMatch ? `Plugin: ${pluginMatch[1].replaceAll("_", " ")}` : `Plugin-related feedback on ${pageLabel}`; + } + if (scope === "system") return `System area: ${pageLabel}`; + if (scope === "other") return `Other feedback on ${pageLabel}`; + return `Whole page: ${pageLabel}`; + }; + + const updateGeneratedScopeLabel = () => { + if (!feedbackForm) return; + const scopeSelect = feedbackForm.querySelector("[data-feedback-scope-type]"); + const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]"); + if (!scopeSelect || !scopeLabel) return; + scopeLabel.value = generatedScopeLabel(scopeSelect.value, activeFeedbackMetadata); + }; + const clearFeedbackHighlight = () => { highlightedFeedbackTarget?.classList?.remove("feedback-target-highlight"); highlightedFeedbackTarget = null; @@ -1059,23 +1121,24 @@ hideContextMenu(); clearFeedbackHighlight(); feedbackForm.reset(); + clearAttachedScreenshot(); setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug"); setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor"); setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, scope); const metadata = target ? feedbackTargetMetadata(target) : {}; + activeFeedbackTarget = target; + activeFeedbackMetadata = metadata; const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]"); const urlInput = feedbackForm.querySelector("[data-feedback-current-url]"); const titleInput = feedbackForm.querySelector("[data-feedback-page-title]"); const diagnosticsInput = feedbackForm.querySelector("[data-feedback-diagnostics]"); - const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]"); const status = feedbackForm.querySelector("[data-feedback-status]"); if (targetInput) targetInput.value = JSON.stringify(metadata); if (urlInput) urlInput.value = window.location.href; if (titleInput) titleInput.value = document.title; if (diagnosticsInput) diagnosticsInput.value = "{}"; - if (scopeLabel) scopeLabel.value = scope === "element" - ? (metadata.label || metadata.text || metadata.selector || "Clicked element") - : currentPageLabel(); + updateGeneratedScopeLabel(); + clearSimilarFeedback(); if (status) status.textContent = ""; if (target && scope === "element") { highlightedFeedbackTarget = target; @@ -1091,6 +1154,8 @@ feedbackModal.classList.remove("is-open"); feedbackModal.setAttribute("aria-hidden", "true"); clearFeedbackHighlight(); + activeFeedbackTarget = null; + activeFeedbackMetadata = {}; }; document.addEventListener("click", (event) => { @@ -1106,28 +1171,174 @@ } }); + feedbackForm?.querySelector("[data-feedback-scope-type]")?.addEventListener("change", updateGeneratedScopeLabel); + ["input", "change"].forEach((eventName) => { + feedbackForm?.addEventListener(eventName, (event) => { + if (!event.target.matches?.("input[name='summary'], textarea[name='description'], select[name='category'], select[name='scope_type']")) return; + scheduleSimilarFeedbackLookup(); + }); + }); + + feedbackForm?.addEventListener("click", async (event) => { + const supportButton = event.target.closest("[data-feedback-support-similar]"); + if (!supportButton) return; + event.preventDefault(); + supportButton.disabled = true; + try { + const response = await fetch(`/api/feedback/${encodeURIComponent(supportButton.dataset.feedbackSupportSimilar)}/support`, { + method: "POST", + headers: { Accept: "application/json" } + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Could not mark this as affecting you."); + supportButton.textContent = `Affects me too (${result.support_count})`; + } catch (error) { + supportButton.disabled = false; + supportButton.textContent = error.message || "Could not mark"; + } + }); + + feedbackForm?.querySelector("[data-feedback-screenshot-file]")?.addEventListener("change", (event) => { + const file = event.target.files?.[0]; + if (file) { + setAttachedScreenshot(file, "upload", file.name || "feedback-screenshot.png"); + } + event.target.value = ""; + }); + + feedbackForm?.querySelector("[data-feedback-paste-screenshot]")?.addEventListener("click", async () => { + await pasteFeedbackScreenshot(); + }); + + feedbackModal?.addEventListener("paste", (event) => { + const file = imageFileFromClipboardData(event.clipboardData); + if (!file) return; + event.preventDefault(); + setAttachedScreenshot(file, "paste", file.name || "feedback-screenshot.png"); + }); + + feedbackForm?.querySelector("[data-feedback-remove-screenshot]")?.addEventListener("click", clearAttachedScreenshot); + + function clearSimilarFeedback() { + feedbackSimilarController?.abort?.(); + feedbackSimilarController = null; + window.clearTimeout(feedbackSimilarTimer); + const box = feedbackForm?.querySelector("[data-feedback-similar]"); + const list = feedbackForm?.querySelector("[data-feedback-similar-list]"); + if (box) box.hidden = true; + if (list) list.replaceChildren(); + } + + function scheduleSimilarFeedbackLookup() { + window.clearTimeout(feedbackSimilarTimer); + feedbackSimilarTimer = window.setTimeout(loadSimilarFeedback, 450); + } + + async function loadSimilarFeedback() { + if (!feedbackForm) return; + const summary = feedbackForm.querySelector("input[name='summary']")?.value || ""; + const description = feedbackForm.querySelector("textarea[name='description']")?.value || ""; + if (summary.trim().length < 6 && description.trim().length < 12) { + clearSimilarFeedback(); + return; + } + feedbackSimilarController?.abort?.(); + feedbackSimilarController = new AbortController(); + const params = new URLSearchParams({ + summary, + description, + category: feedbackForm.querySelector("select[name='category']")?.value || "", + scope_type: feedbackForm.querySelector("select[name='scope_type']")?.value || "", + current_url: window.location.href, + limit: "4" + }); + try { + const response = await fetch(`/api/feedback/similar?${params.toString()}`, { + headers: { Accept: "application/json" }, + signal: feedbackSimilarController.signal + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Could not load similar feedback."); + renderSimilarFeedback(result.matches || []); + } catch (error) { + if (error.name !== "AbortError") { + renderSimilarFeedback([]); + } + } + } + + function renderSimilarFeedback(matches) { + const box = feedbackForm?.querySelector("[data-feedback-similar]"); + const list = feedbackForm?.querySelector("[data-feedback-similar-list]"); + if (!box || !list) return; + list.replaceChildren(); + if (!matches.length) { + box.hidden = true; + return; + } + matches.forEach((item) => { + const row = document.createElement("article"); + row.className = "feedback-similar-item"; + const title = document.createElement("strong"); + title.textContent = item.summary; + const meta = document.createElement("span"); + meta.className = "hint"; + meta.textContent = `${item.category_label} · ${item.scope_label_display} · ${item.status_label} · ${item.support_count || 0} also affected`; + const actions = document.createElement("div"); + actions.className = "button-group"; + const open = document.createElement("a"); + open.className = "button subtle"; + open.href = `/feedback?id=${encodeURIComponent(item.id)}`; + open.textContent = "Open"; + const support = document.createElement("button"); + support.type = "button"; + support.className = "button subtle"; + support.dataset.feedbackSupportSimilar = item.id; + support.disabled = Boolean(item.supported_by_me); + support.textContent = item.supported_by_me ? "Affects me too" : "This affects me too"; + actions.append(open, support); + row.append(title, meta, actions); + list.append(row); + }); + box.hidden = false; + } + feedbackForm?.addEventListener("submit", async (event) => { event.preventDefault(); const submitter = feedbackForm.querySelector("button[type='submit']"); const status = feedbackForm.querySelector("[data-feedback-status]"); const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]")?.checked; + const includeDom = feedbackForm.querySelector("[data-feedback-include-dom]")?.checked; const formData = new FormData(feedbackForm); - const payload = Object.fromEntries(formData.entries()); - payload.target_metadata = parseJson(payload.target_metadata, {}); - payload.diagnostics = includeBrowser - ? { - user_agent: navigator.userAgent, - viewport: `${window.innerWidth}x${window.innerHeight}`, - language: navigator.language || "" - } - : {}; + formData.set("target_metadata", JSON.stringify(parseJson(formData.get("target_metadata"), {}))); + formData.set("diagnostics", JSON.stringify({ + ...(includeBrowser + ? { + user_agent: navigator.userAgent, + viewport: `${window.innerWidth}x${window.innerHeight}`, + language: navigator.language || "" + } + : {}), + ...(includeDom + ? { dom_snapshot: sanitizedDomSnapshot(activeFeedbackTarget || document.querySelector("main") || document.body) } + : {}), + ...(attachedScreenshotBlob + ? { + screenshot_mode: "user_attached", + screenshot_source: attachedScreenshotSource + } + : {}) + })); + if (attachedScreenshotBlob) { + formData.set("screenshot", attachedScreenshotBlob, attachedScreenshotName); + } submitter.disabled = true; if (status) status.textContent = "Submitting feedback..."; try { const response = await fetch("/api/feedback", { method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify(payload) + headers: { Accept: "application/json" }, + body: formData }); const result = await response.json(); if (!response.ok) throw new Error(result.error || "Feedback could not be submitted."); @@ -1142,35 +1353,221 @@ } }); - const isEditableTarget = (target) => { + function sanitizedDomSnapshot(root) { + const lines = []; + const maxNodes = 80; + const maxDepth = 4; + const skipTags = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]); + const walk = (node, depth = 0) => { + if (!node || lines.length >= maxNodes || depth > maxDepth) return; + if (node.nodeType !== 1 || skipTags.has(node.tagName)) return; + const indent = " ".repeat(depth); + const tag = node.tagName.toLowerCase(); + const attrs = []; + if (node.id) attrs.push(`#${node.id.slice(0, 60)}`); + if (node.classList?.length) attrs.push(`.${Array.from(node.classList).slice(0, 3).join(".").slice(0, 80)}`); + const role = node.getAttribute?.("role"); + const label = node.getAttribute?.("aria-label") || node.getAttribute?.("title"); + if (role) attrs.push(`role=${role.slice(0, 40)}`); + if (label) attrs.push(`label="${safeText(label, 80)}"`); + const sensitive = node.matches?.(sensitiveSelector) || ["INPUT", "TEXTAREA", "SELECT"].includes(node.tagName); + const text = sensitive ? "" : safeElementText(node); + lines.push(`${indent}<${tag}${attrs.length ? ` ${attrs.join(" ")}` : ""}>${text ? ` ${safeText(text, 120)}` : ""}`); + Array.from(node.children || []).slice(0, 10).forEach((child) => walk(child, depth + 1)); + }; + walk(root); + return lines.join("\n").slice(0, 6000); + } + + function clearAttachedScreenshot() { + if (attachedScreenshotUrl) { + URL.revokeObjectURL(attachedScreenshotUrl); + } + attachedScreenshotBlob = null; + attachedScreenshotUrl = ""; + attachedScreenshotSource = ""; + attachedScreenshotName = "feedback-screenshot.png"; + const preview = feedbackForm?.querySelector("[data-feedback-screenshot-preview]"); + const image = feedbackForm?.querySelector("[data-feedback-screenshot-image]"); + const meta = feedbackForm?.querySelector("[data-feedback-screenshot-meta]"); + const removeButton = feedbackForm?.querySelector("[data-feedback-remove-screenshot]"); + const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]"); + if (preview) preview.hidden = true; + if (image) image.removeAttribute("src"); + if (meta) meta.textContent = ""; + if (removeButton) removeButton.hidden = true; + if (screenshotStatus) screenshotStatus.textContent = ""; + } + + async function pasteFeedbackScreenshot() { + const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]"); + if (screenshotStatus) { + screenshotStatus.textContent = "Looking for an image in the clipboard..."; + } + try { + if (!navigator.clipboard?.read || !window.isSecureContext) { + throw new Error("Clipboard image read is not available."); + } + const items = await navigator.clipboard.read(); + for (const item of items) { + const type = item.types.find((entry) => entry.startsWith("image/")); + if (type) { + const blob = await item.getType(type); + setAttachedScreenshot(blob, "paste", `feedback-screenshot${screenshotExtension(type)}`); + return; + } + } + throw new Error("No image was found in the clipboard."); + } catch (error) { + if (screenshotStatus) { + screenshotStatus.textContent = `${error.message || "Clipboard image could not be read."} You can also copy a screenshot and press Ctrl+V while this dialog is open.`; + } + } + } + + function imageFileFromClipboardData(data) { + const files = Array.from(data?.files || []); + const file = files.find((entry) => entry.type?.startsWith("image/")); + if (file) return file; + const items = Array.from(data?.items || []); + const item = items.find((entry) => entry.type?.startsWith("image/")); + return item?.getAsFile?.() || null; + } + + function screenshotExtension(mime) { + if (mime === "image/jpeg") return ".jpg"; + if (mime === "image/webp") return ".webp"; + return ".png"; + } + + function screenshotLabel(source) { + return source === "paste" ? "Pasted screenshot" : "Uploaded screenshot"; + } + + function setAttachedScreenshot(blob, source, name = "feedback-screenshot.png") { + const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]"); + if (!["image/png", "image/jpeg", "image/webp"].includes(blob.type)) { + if (screenshotStatus) screenshotStatus.textContent = "Only PNG, JPEG, or WebP screenshots can be attached."; + return; + } + if (blob.size > 8 * 1024 * 1024) { + if (screenshotStatus) screenshotStatus.textContent = "Screenshot must be 8 MB or smaller."; + return; + } + clearAttachedScreenshot(); + attachedScreenshotBlob = blob; + attachedScreenshotUrl = URL.createObjectURL(blob); + attachedScreenshotSource = source; + attachedScreenshotName = name || `feedback-screenshot${screenshotExtension(blob.type)}`; + const preview = feedbackForm?.querySelector("[data-feedback-screenshot-preview]"); + const image = feedbackForm?.querySelector("[data-feedback-screenshot-image]"); + const meta = feedbackForm?.querySelector("[data-feedback-screenshot-meta]"); + const removeButton = feedbackForm?.querySelector("[data-feedback-remove-screenshot]"); + const status = feedbackForm?.querySelector("[data-feedback-screenshot-status]"); + if (image && attachedScreenshotUrl) { + image.src = attachedScreenshotUrl; + } + if (meta) { + const sizeKb = Math.max(1, Math.round(blob.size / 1024)); + meta.textContent = `${screenshotLabel(source)} · ${blob.type.replace("image/", "").toUpperCase()} · ${sizeKb} KB`; + } + if (preview) preview.hidden = !attachedScreenshotUrl; + if (removeButton) removeButton.hidden = false; + if (status) status.textContent = "Screenshot attached. Review the preview and remove it if it contains sensitive information."; + } + + const editableElementForTarget = (target) => { const element = target?.closest?.("input, textarea, [contenteditable='true']"); - return element && !element.matches("[readonly], [disabled]"); + if (!element || element.matches("[readonly], [disabled]")) return null; + const inputType = String(element.getAttribute("type") || "").toLowerCase(); + if (["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(inputType)) { + return null; + } + return element; }; - const updateContextMenuItems = (target) => { + const selectedTextForTarget = (target) => { + const editable = editableElementForTarget(target); + if (editable && typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") { + return editable.value.slice(editable.selectionStart, editable.selectionEnd); + } + return window.getSelection?.().toString() || ""; + }; + + const hasInputSelection = (target) => { + const editable = editableElementForTarget(target); + if (!editable) return false; + if (typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") { + return editable.selectionEnd > editable.selectionStart; + } + const selection = window.getSelection?.(); + return Boolean(selection && !selection.isCollapsed && editable.contains(selection.anchorNode)); + }; + + const contextImageForTarget = (target) => target?.closest?.("img") || null; + + const readClipboardText = async () => { + try { + if (!navigator.clipboard?.readText || !window.isSecureContext) return ""; + return await navigator.clipboard.readText(); + } catch { + return ""; + } + }; + + const setContextActionEnabled = (action, enabled) => { + const button = contextMenu?.querySelector(`[data-context-action="${action}"]`); + if (!button) return; + button.setAttribute("aria-disabled", enabled ? "false" : "true"); + button.classList.toggle("is-disabled", !enabled); + button.tabIndex = enabled ? 0 : -1; + }; + + const enabledContextButtons = () => + Array.from(contextMenu?.querySelectorAll("[data-context-action]:not(.is-disabled)") || []); + + const focusContextButton = (offset) => { + const buttons = enabledContextButtons(); + if (!buttons.length) return; + const current = buttons.indexOf(document.activeElement); + const next = current === -1 ? 0 : (current + offset + buttons.length) % buttons.length; + buttons[next].focus(); + }; + + const updateContextMenuItems = async (target) => { if (!contextMenu) return; - const editable = Boolean(isEditableTarget(target)); - contextMenu.querySelector("[data-context-action='cut']")?.toggleAttribute("hidden", !editable); - contextMenu.querySelector("[data-context-action='paste']")?.toggleAttribute("hidden", !editable); - contextMenu.querySelector("[data-context-action='copy']")?.toggleAttribute("hidden", false); + const active = document.activeElement; + const pasteTarget = editableElementForTarget(active); + const selectedText = selectedTextForTarget(target).trim(); + const image = contextImageForTarget(target); + contextClipboardText = pasteTarget ? await readClipboardText() : ""; + setContextActionEnabled("back", window.history.length > 1); + setContextActionEnabled("forward", Boolean(window.navigation?.canGoForward)); + setContextActionEnabled("copy", Boolean(selectedText || image)); + setContextActionEnabled("cut", hasInputSelection(target)); + setContextActionEnabled("paste", Boolean(pasteTarget && contextClipboardText.length > 0)); + setContextActionEnabled("link", true); + setContextActionEnabled("reload", true); + setContextActionEnabled("feedback", true); }; - const showContextMenu = (event) => { + const showContextMenu = async (event) => { if (!contextMenu || event.shiftKey) return; event.preventDefault(); contextTarget = event.target; - updateContextMenuItems(contextTarget); contextMenu.hidden = false; + await updateContextMenuItems(contextTarget); const width = contextMenu.offsetWidth || 180; const height = contextMenu.offsetHeight || 260; contextMenu.style.left = `${Math.min(event.clientX, window.innerWidth - width - 8)}px`; contextMenu.style.top = `${Math.min(event.clientY, window.innerHeight - height - 8)}px`; - contextMenu.querySelector("button:not([hidden])")?.focus(); + contextMenu.querySelector("button:not(.is-disabled)")?.focus(); }; function hideContextMenu() { if (!contextMenu) return; contextMenu.hidden = true; + hideContextTooltip(); } const linkForTarget = (target) => { @@ -1180,19 +1577,49 @@ return url.toString(); }; - const runContextAction = async (action) => { - const editable = isEditableTarget(contextTarget); + const copyImage = async (image) => { + if (!image) return false; + try { + if (!navigator.clipboard?.write || !window.ClipboardItem || !window.isSecureContext) return false; + const response = await fetch(image.currentSrc || image.src); + const blob = await response.blob(); + await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]); + return true; + } catch { + return false; + } + }; + + const insertTextIntoEditable = (editable, text) => { + if (!editable || !text) return; + editable.focus(); + if (typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") { + const start = editable.selectionStart; + const end = editable.selectionEnd; + editable.setRangeText(text, start, end, "end"); + editable.dispatchEvent(new Event("input", { bubbles: true })); + return; + } + document.execCommand("insertText", false, text); + }; + + const runContextAction = async (action, button) => { + if (button?.classList.contains("is-disabled") || button?.getAttribute("aria-disabled") === "true") return; + const editable = editableElementForTarget(contextTarget) || editableElementForTarget(document.activeElement); try { if (action === "back") window.history.back(); - if (action === "forward") window.history.forward(); + if (action === "forward") { + if (window.navigation?.canGoForward) window.history.forward(); + } if (action === "copy") { - const selection = window.getSelection?.().toString(); - await copyText(selection || linkForTarget(contextTarget)); + const selectedText = selectedTextForTarget(contextTarget); + if (selectedText) await copyText(selectedText); + else await copyImage(contextImageForTarget(contextTarget)); } if (action === "cut" && editable) document.execCommand("cut"); if (action === "paste" && editable) { - const text = await navigator.clipboard?.readText?.(); - if (text !== undefined) document.execCommand("insertText", false, text); + const text = contextClipboardText || await readClipboardText(); + insertTextIntoEditable(editable, text); } if (action === "link") await copyText(linkForTarget(contextTarget)); if (action === "reload") window.location.reload(true); @@ -1212,13 +1639,91 @@ return; } event.preventDefault(); - runContextAction(button.dataset.contextAction); + runContextAction(button.dataset.contextAction, button); + }); + contextMenu?.addEventListener("pointerover", (event) => { + const button = event.target.closest("[data-context-action]"); + if (!button || !contextMenu.contains(button)) return; + window.clearTimeout(contextTooltipTimer); + contextTooltipTimer = window.setTimeout(() => showContextTooltip(button), 1000); + }); + contextMenu?.addEventListener("pointerout", (event) => { + const button = event.target.closest("[data-context-action]"); + if (!button) return; + const next = event.relatedTarget; + if (next && (button.contains(next) || contextTooltip?.contains(next))) return; + window.clearTimeout(contextTooltipTimer); + hideContextTooltip(); }); document.addEventListener("keydown", (event) => { + if (!contextMenu?.hidden && contextMenu?.contains(document.activeElement)) { + if (event.key === "ArrowDown") { + event.preventDefault(); + focusContextButton(1); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + focusContextButton(-1); + return; + } + if (event.key === "Home") { + event.preventDefault(); + enabledContextButtons()[0]?.focus(); + return; + } + if (event.key === "End") { + event.preventDefault(); + enabledContextButtons().at(-1)?.focus(); + return; + } + if (event.key === "Enter" || event.key === " ") { + const button = document.activeElement?.closest?.("[data-context-action]"); + if (button) { + event.preventDefault(); + runContextAction(button.dataset.contextAction, button); + return; + } + } + if (event.key === "Tab") { + event.preventDefault(); + focusContextButton(event.shiftKey ? -1 : 1); + return; + } + } if (event.key === "Escape") { hideContextMenu(); closeFeedbackModal(); } }); window.addEventListener("blur", hideContextMenu); + + function showContextTooltip(button) { + const text = button?.dataset.contextTooltip || ""; + if (!text) return; + hideContextTooltip(); + contextTooltip = document.createElement("div"); + contextTooltip.className = "lumi-context-tooltip"; + contextTooltip.textContent = text; + contextTooltip.setAttribute("role", "tooltip"); + document.body.append(contextTooltip); + const buttonRect = button.getBoundingClientRect(); + const tooltipRect = contextTooltip.getBoundingClientRect(); + const padding = 8; + let left = buttonRect.right + padding; + let top = buttonRect.top + (buttonRect.height - tooltipRect.height) / 2; + if (left + tooltipRect.width > window.innerWidth - padding) { + left = buttonRect.left - tooltipRect.width - padding; + } + left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding)); + top = Math.max(padding, Math.min(top, window.innerHeight - tooltipRect.height - padding)); + contextTooltip.style.left = `${left}px`; + contextTooltip.style.top = `${top}px`; + } + + function hideContextTooltip() { + window.clearTimeout(contextTooltipTimer); + contextTooltip?.remove(); + contextTooltip = null; + } })(); diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 8d81f74..5d9ad15 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -351,6 +351,30 @@ button:disabled { width: 100%; } +.button-group { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--lumi-space-2); + align-items: center; +} + +.form-grid .field.button-group, +.field.button-group { + flex-direction: row; +} + +.button-group.centered { + justify-content: center; +} + +.button-group .button, +.button-group button.button, +.button-group input[type="submit"].button, +.button-group .lumi-state-btn { + flex: 0 0 auto; +} + .lumi-state-btn-spinner { width: 1em; height: 1em; @@ -1107,6 +1131,16 @@ input[type="color"] { box-shadow: var(--lumi-shadow-lg); } +.modal.feedback-modal { + width: min(50vw, 60rem); + min-width: min(92vw, 45rem); + max-height: 75vh; +} + +.feedback-modal .modal-actions { + justify-content: center; +} + .modal-header h2, .modal-header h3 { margin-bottom: 0; @@ -1246,6 +1280,19 @@ details > summary { padding: var(--lumi-space-3); } + .feedback-modal-backdrop { + align-items: stretch; + padding: var(--lumi-space-2); + } + + .modal.feedback-modal { + width: 100%; + min-width: 0; + max-height: calc(100dvh - (var(--lumi-space-2) * 2)); + min-height: calc(100dvh - (var(--lumi-space-2) * 2)); + padding: var(--lumi-space-3); + } + .hero { padding: var(--lumi-space-4); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e952975..9486f90 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1285,16 +1285,97 @@ body { text-align: center; } -.feedback-modal { - max-width: 820px; -} - .feedback-submit-status { min-height: 20px; color: var(--ink-soft); font-weight: 700; } +.feedback-screenshot-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: center; +} + +.feedback-screenshot-actions label.button { + margin: 0; + cursor: pointer; +} + +.feedback-screenshot-guide { + display: grid; + gap: 4px; + margin: 2px 0 10px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--sea) 35%, var(--border)); + border-radius: 12px; + background: color-mix(in srgb, var(--sea) 9%, var(--surface-2)); +} + +.feedback-screenshot-guide p { + margin: 0; + max-width: none; +} + +.feedback-screenshot-preview { + display: inline-flex; + gap: 10px; + align-items: center; + width: fit-content; + max-width: 100%; + margin-top: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-2); +} + +.feedback-screenshot-preview[hidden] { + display: none; +} + +.feedback-screenshot-preview img { + width: auto; + max-width: min(260px, 42vw); + max-height: 160px; + border-radius: 8px; + object-fit: contain; + background: var(--surface-3); +} + +.feedback-screenshot-preview img:not([src]) { + display: none; +} + +.feedback-similar { + padding: 12px; + border: 1px solid color-mix(in srgb, var(--sun) 40%, var(--border)); + border-radius: 12px; + background: color-mix(in srgb, var(--sun) 8%, var(--surface-2)); +} + +.feedback-similar-list { + display: grid; + gap: 8px; +} + +.feedback-similar-item { + display: grid; + gap: 6px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-2); +} + +.feedback-attachment-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .feedback-page-actions, .feedback-badge-row, .feedback-two-col, @@ -1418,7 +1499,8 @@ body { } .feedback-status-fixed, -.feedback-status-solved { +.feedback-status-solved, +.feedback-status-closed { border-color: rgba(48, 170, 112, 0.45); color: var(--success); } @@ -1517,6 +1599,32 @@ body { background: var(--surface-2); } +.lumi-context-menu button.is-disabled { + color: var(--ink-soft); + cursor: not-allowed; + opacity: 0.48; +} + +.lumi-context-menu button.is-disabled:hover, +.lumi-context-menu button.is-disabled:focus { + background: transparent; +} + +.lumi-context-tooltip { + position: fixed; + z-index: 130; + max-width: min(280px, calc(100vw - 16px)); + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-3); + color: var(--ink); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26); + font-size: 13px; + line-height: 1.35; + pointer-events: none; +} + .checkbox-inline { display: inline-flex; gap: 8px; @@ -1786,6 +1894,33 @@ body .modal-backdrop.destructive-confirm-modal { } @media (max-width: 900px) { + .feedback-modal .form-grid { + grid-template-columns: 1fr; + } + + .feedback-modal .field.full { + grid-column: auto; + } + + .feedback-screenshot-actions, + .feedback-modal .modal-actions { + align-items: stretch; + flex-direction: column; + } + + .feedback-screenshot-actions .button, + .feedback-modal .modal-actions .button { + width: 100%; + } + + .feedback-screenshot-preview { + width: 100%; + } + + .feedback-screenshot-preview img { + max-width: 100%; + } + .app-shell { grid-template-columns: 1fr; } diff --git a/src/web/server.js b/src/web/server.js index 53d5cac..7c74b2b 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -113,15 +113,21 @@ const { const { addSubmitterComment, adminUpdateFeedback, - convertFeedbackToTodo, + cleanupFeedback, createFeedback, + deleteFeedback, + findSimilarFeedback, feedbackOptions, getFeedbackForSubmitter, + getFeedbackForAdmin, + getFeedbackAttachment, + getFeedbackForViewer, listFeedbackForAdmin, listMyFeedback, listPublicFeedback, markFeedbackViewed, - notificationSummary + notificationSummary, + supportFeedback } = require("../services/feedback"); function ensureSessionSecret() { @@ -1683,6 +1689,60 @@ function setFlash(req, type, message) { req.session.flash = { type, message }; } +function screenshotExtension(mime) { + if (mime === "image/jpeg") return ".jpg"; + if (mime === "image/webp") return ".webp"; + return ".png"; +} + +function feedbackAttachmentExtension(file = {}) { + const original = path.extname(file.originalname || "").slice(0, 12).toLowerCase(); + if ([".png", ".jpg", ".jpeg", ".webp", ".pdf", ".txt"].includes(original)) return original; + if (file.mimetype === "application/pdf") return ".pdf"; + if (file.mimetype === "text/plain") return ".txt"; + return screenshotExtension(file.mimetype); +} + +function normalizeFeedbackRequestBody(body = {}) { + return { + ...body, + target_metadata: parseBodyJson(body.target_metadata), + diagnostics: parseBodyJson(body.diagnostics) + }; +} + +function parseBodyJson(value) { + if (value && typeof value === "object") return value; + try { + const parsed = JSON.parse(String(value || "{}")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function resolveFeedbackScreenshotPath(relativePath, screenshotDir) { + const normalized = String(relativePath || "").replace(/\\/g, "/"); + const prefix = "feedback/screenshots/"; + if (!normalized.startsWith(prefix)) return null; + const fileName = path.basename(normalized.slice(prefix.length)); + if (!fileName || fileName !== normalized.slice(prefix.length)) return null; + const target = path.resolve(screenshotDir, fileName); + const root = path.resolve(screenshotDir); + return target.startsWith(`${root}${path.sep}`) ? target : null; +} + +function resolveFeedbackAttachmentPath(relativePath, attachmentDir) { + const normalized = String(relativePath || "").replace(/\\/g, "/"); + const prefix = "feedback/attachments/"; + if (!normalized.startsWith(prefix)) return null; + const fileName = path.basename(normalized.slice(prefix.length)); + if (!fileName || fileName !== normalized.slice(prefix.length)) return null; + const target = path.resolve(attachmentDir, fileName); + const root = path.resolve(attachmentDir); + return target.startsWith(`${root}${path.sep}`) ? target : null; +} + function getThemeSettings() { return getActiveTheme(); } @@ -2488,6 +2548,10 @@ function createWebServer({ loadPlugins, discordClient }) { const navIconDir = path.join(__dirname, "..", "..", "data", "nav-icons"); fs.mkdirSync(navIconDir, { recursive: true }); app.use("/assets/nav-icons", express.static(navIconDir)); + const feedbackScreenshotDir = path.join(__dirname, "..", "..", "data", "feedback", "screenshots"); + fs.mkdirSync(feedbackScreenshotDir, { recursive: true }); + const feedbackAttachmentDir = path.join(__dirname, "..", "..", "data", "feedback", "attachments"); + fs.mkdirSync(feedbackAttachmentDir, { recursive: true }); const upload = multer ? multer({ dest: uploadDir }) : null; const uploadSingle = (fieldName) => { if (!upload) { @@ -2522,6 +2586,41 @@ function createWebServer({ loadPlugins, discordClient }) { } return navIconUpload.single("icon_file")(req, _res, next); }; + const feedbackScreenshotUpload = + multer && + multer({ + storage: multer.diskStorage({ + destination: (_req, file, cb) => cb(null, file.fieldname === "attachments" ? feedbackAttachmentDir : feedbackScreenshotDir), + filename: (_req, file, cb) => { + const ext = file.fieldname === "attachments" ? feedbackAttachmentExtension(file) : screenshotExtension(file.mimetype); + cb(null, `${crypto.randomUUID()}${ext}`); + } + }), + limits: { fileSize: 8 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.fieldname === "screenshot" && ["image/png", "image/jpeg", "image/webp"].includes(file.mimetype)) { + return cb(null, true); + } + if (file.fieldname === "attachments" && ["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(file.mimetype)) { + return cb(null, true); + } + cb(new Error("Only PNG, JPEG, WebP, PDF, or text feedback files are allowed.")); + } + }); + const feedbackUploadFields = (req, res, next) => { + if (!feedbackScreenshotUpload) { + return next(); + } + return feedbackScreenshotUpload.fields([ + { name: "screenshot", maxCount: 1 }, + { name: "attachments", maxCount: 3 } + ])(req, res, (error) => { + if (error) { + req.feedbackUploadError = error.message; + } + next(); + }); + }; const navItems = []; const profileSections = []; @@ -2579,15 +2678,59 @@ function createWebServer({ loadPlugins, discordClient }) { res.status(400).json({ error: error.message }); } }); - app.post("/api/feedback", requireAuth, (req, res) => { + app.get("/api/feedback/similar", requireAuth, (req, res) => { try { - const entry = createFeedback(req.body, req.session.user); + res.json({ + ok: true, + matches: findSimilarFeedback(req.query, { + userId: req.session.user.id, + limit: req.query.limit || 5 + }) + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } + }); + app.post("/api/feedback/:id/support", requireAuth, (req, res) => { + try { + const support_count = supportFeedback(req.params.id, req.session.user); + res.json({ ok: true, support_count }); + } catch (error) { + res.status(400).json({ error: error.message }); + } + }); + app.post("/api/feedback", requireAuth, feedbackUploadFields, (req, res) => { + try { + if (req.feedbackUploadError) { + throw new Error(req.feedbackUploadError); + } + const body = normalizeFeedbackRequestBody(req.body); + const screenshotFile = req.files?.screenshot?.[0] || null; + const attachmentFiles = req.files?.attachments || []; + const entry = createFeedback(body, req.session.user, { + screenshot: screenshotFile + ? { + path: `feedback/screenshots/${screenshotFile.filename}`, + mime: screenshotFile.mimetype, + size: screenshotFile.size + } + : null, + attachments: attachmentFiles.map((file) => ({ + path: `feedback/attachments/${file.filename}`, + mime: file.mimetype, + size: file.size, + original_name: file.originalname + })) + }); res.status(201).json({ ok: true, id: entry.id, message: "Feedback submitted. You can review it from the Feedback page." }); } catch (error) { + [...(req.files?.screenshot || []), ...(req.files?.attachments || [])].forEach((file) => { + if (file?.path) fs.rmSync(file.path, { force: true }); + }); res.status(400).json({ error: error.message }); } }); @@ -3698,7 +3841,7 @@ function createWebServer({ loadPlugins, discordClient }) { markFeedbackViewed(req.session.user.id); res.locals.feedbackNotifications = notificationSummary(req.session.user.id); const selected = req.query.id - ? getFeedbackForSubmitter(String(req.query.id), req.session.user.id) + ? getFeedbackForViewer(String(req.query.id), req.session.user.id) : null; res.render("feedback", { title: "Feedback", @@ -3719,6 +3862,64 @@ function createWebServer({ loadPlugins, discordClient }) { res.redirect(`/feedback?id=${encodeURIComponent(req.params.id)}`); }); + app.post("/feedback/:id/support", requireAuth, (req, res) => { + try { + supportFeedback(req.params.id, req.session.user); + setFlash(req, "success", "Feedback supported."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/feedback"); + }); + + app.get("/feedback/:id/screenshot", requireAuth, (req, res) => { + const entry = req.session.user?.isAdmin + ? getFeedbackForAdmin(req.params.id) + : getFeedbackForSubmitter(req.params.id, req.session.user.id); + if (!entry?.screenshot?.path) { + return res.status(404).render("error", { + title: "Screenshot not found", + message: "That feedback screenshot is not available." + }); + } + const screenshotPath = resolveFeedbackScreenshotPath(entry.screenshot.path, feedbackScreenshotDir); + if (!screenshotPath || !fs.existsSync(screenshotPath)) { + return res.status(404).render("error", { + title: "Screenshot not found", + message: "That feedback screenshot is no longer available." + }); + } + res.setHeader("Content-Type", entry.screenshot.mime || "image/png"); + res.setHeader("Cache-Control", "private, no-store"); + res.sendFile(screenshotPath); + }); + + app.get("/feedback/:id/attachments/:attachmentId", requireAuth, (req, res) => { + const attachment = getFeedbackAttachment( + req.params.id, + req.params.attachmentId, + req.session.user.id, + Boolean(req.session.user?.isAdmin) + ); + if (!attachment?.storage_path) { + return res.status(404).render("error", { + title: "Attachment not found", + message: "That feedback attachment is not available." + }); + } + const attachmentPath = resolveFeedbackAttachmentPath(attachment.storage_path, feedbackAttachmentDir); + if (!attachmentPath || !fs.existsSync(attachmentPath)) { + return res.status(404).render("error", { + title: "Attachment not found", + message: "That feedback attachment is no longer available." + }); + } + res.setHeader("Content-Type", attachment.mime || "application/octet-stream"); + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("Content-Disposition", `attachment; filename="${path.basename(attachment.original_name || "attachment")}"`); + res.sendFile(attachmentPath); + }); + app.get("/health", (req, res) => { res.set("Cache-Control", "no-store"); res.json({ ok: true, ts: Date.now() }); @@ -4987,7 +5188,10 @@ function createWebServer({ loadPlugins, discordClient }) { category: req.query.category || "", severity: req.query.severity || "", scope: req.query.scope || "", + area: req.query.area || "", submitter: req.query.submitter || "", + date_from: req.query.date_from || "", + date_to: req.query.date_to || "", needs_action: req.query.needs_action || "", sort: req.query.sort || "last_activity" }, @@ -4997,18 +5201,94 @@ function createWebServer({ loadPlugins, discordClient }) { app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => { try { - adminUpdateFeedback(req.params.id, req.body, req.session.user); - setFlash(req, "success", "Feedback updated."); + const body = { ...req.body }; + if (body.review_action === "finalize") { + body.status = "closed"; + body.status_note = body.status_note || "Finalized and closed."; + } else if (body.review_action === "reopen") { + body.status = "reviewed"; + body.status_note = body.status_note || "Reopened for review."; + } + adminUpdateFeedback(req.params.id, body, req.session.user); + setFlash(req, "success", body.review_action === "finalize" + ? "Feedback finalized and closed." + : body.review_action === "reopen" + ? "Feedback reopened." + : "Feedback updated."); } catch (error) { setFlash(req, "error", error.message); } res.redirect(req.get("referer") || "/admin/feedback"); }); - app.post("/admin/feedback/:id/todo", requireRole("admin"), (req, res) => { + app.post("/admin/feedback/:id/finalize", requireRole("admin"), (req, res) => { try { - convertFeedbackToTodo(req.params.id, req.body.todo_text, req.session.user); - setFlash(req, "success", "Feedback converted to TODO."); + adminUpdateFeedback(req.params.id, { + ...req.body, + status: "closed", + status_note: req.body.status_note || "Finalized and closed." + }, req.session.user); + setFlash(req, "success", "Feedback finalized and closed."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/admin/feedback"); + }); + + app.post("/admin/feedback/:id/reopen", requireRole("admin"), (req, res) => { + try { + adminUpdateFeedback(req.params.id, { + ...req.body, + status: "reviewed", + status_note: req.body.status_note || "Reopened for review." + }, req.session.user); + setFlash(req, "success", "Feedback reopened."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/admin/feedback"); + }); + + app.post("/admin/feedback/:id/delete", requireRole("admin"), (req, res) => { + try { + deleteFeedback(req.params.id, { + deleteScreenshot(relativePath) { + const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir); + if (screenshotPath) { + fs.rmSync(screenshotPath, { force: true }); + } + }, + deleteAttachment(relativePath) { + const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir); + if (attachmentPath) { + fs.rmSync(attachmentPath, { force: true }); + } + } + }); + setFlash(req, "success", "Feedback permanently deleted."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect("/admin/feedback"); + }); + + app.post("/admin/feedback/:id/cleanup", requireRole("admin"), (req, res) => { + try { + cleanupFeedback(req.params.id, req.body, req.session.user, { + deleteScreenshot(relativePath) { + const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir); + if (screenshotPath) { + fs.rmSync(screenshotPath, { force: true }); + } + }, + deleteAttachment(relativePath) { + const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir); + if (attachmentPath) { + fs.rmSync(attachmentPath, { force: true }); + } + } + }); + setFlash(req, "success", "Feedback data cleaned."); } catch (error) { setFlash(req, "error", error.message); } diff --git a/src/web/views/admin-feedback.ejs b/src/web/views/admin-feedback.ejs index 8d136e2..e3330f1 100644 --- a/src/web/views/admin-feedback.ejs +++ b/src/web/views/admin-feedback.ejs @@ -3,7 +3,7 @@ <%- include("partials/page-header", { eyebrow: "Administration", pageTitle: "Feedback review", - description: "Review core feedback, reply to submitters, record private work notes, and convert useful reports into TODO entries." + description: "Review core feedback, reply to submitters, record private work notes, and close or delete resolved reports." }) %>
+ + + + + + + +
+ +
+
@@ -140,7 +201,7 @@ -
+
-
+
+ <% if (item.status === "closed") { %> + + <% } else { %> + + <% } %> +
- -
<% }) %> diff --git a/src/web/views/feedback.ejs b/src/web/views/feedback.ejs index b2b1987..c771736 100644 --- a/src/web/views/feedback.ejs +++ b/src/web/views/feedback.ejs @@ -55,6 +55,9 @@
Scope<%= selectedFeedback.scope_label_display %>
Submitted<%= new Date(selectedFeedback.created_at).toLocaleString() %>
+ <% if (!selectedFeedback.is_mine) { %> +
Public feedback

You can mark that this affects you too or add public context without seeing private diagnostics or attachments.

+ <% } %>
Description

<%= selectedFeedback.description %>

@@ -71,6 +74,22 @@ <% if (selectedFeedback.admin_reply) { %>
Admin reply

<%= selectedFeedback.admin_reply %>

<% } %> + <% if (selectedFeedback.screenshot) { %> +
+ Attached screenshot +

Open screenshot

+
+ <% } %> + <% if (selectedFeedback.attachments && selectedFeedback.attachments.length) { %> +
+ Attachments + +
+ <% } %>

Conversation

<% if (!selectedFeedback.comments.length) { %>

No comments yet.

@@ -85,11 +104,18 @@ <% }) %>
<% } %> + <% if (!selectedFeedback.is_mine && !selectedFeedback.supported_by_me) { %> +
+ +
+ <% } else if (selectedFeedback.supported_by_me) { %> + Marked as affecting you too. + <% } %>
- + - Use this when an admin asks for more detail or you have extra context. + <%= selectedFeedback.is_mine ? "Use this when an admin asks for more detail or you have extra context." : "Use this when you have the same issue or can add useful public detail." %>
@@ -121,11 +147,21 @@ <%= item.summary %> <% if (item.is_mine) { %>Mine<% } %> + <% if (item.support_count) { %><%= item.support_count %> also affected<% } %> <%= item.category_label %> <%= item.scope_label_display %> - <%= new Date(item.updated_at).toLocaleString() %> + + <%= new Date(item.updated_at).toLocaleString() %> + <% if (!item.is_mine && !item.supported_by_me) { %> + + + + <% } else if (item.supported_by_me) { %> + Marked as affecting you too. + <% } %> + <% }) %> diff --git a/src/web/views/partials/layout-bottom.ejs b/src/web/views/partials/layout-bottom.ejs index 66210b8..622555b 100644 --- a/src/web/views/partials/layout-bottom.ejs +++ b/src/web/views/partials/layout-bottom.ejs @@ -40,6 +40,11 @@
+
@@ -53,14 +58,39 @@
- - + + + Generated from the selected feedback scope and current page or clicked element. +
+
+ +
+ Want to include a screenshot? +

Use your operating system screenshot shortcut, then return here and choose Paste screenshot. This form stays open while you take the screenshot.

+ Only attach a screenshot if you want admins to see it. Remove secrets, tokens, private messages, and sensitive fields first. +
+
+ + + + +
+ +
Please submit one feedback item per issue. Do not include passwords, tokens, or private messages.
+
+ + + Up to three files, 8 MB each. Supported types: PNG, JPEG, WebP, PDF, and plain text. Attachments are visible only to you and admins. +
@@ -80,6 +110,13 @@ This attaches your browser name, page size, language, URL, and page title. Field values are not collected.
+
+ + This attaches a limited text outline of nearby page elements. Input values, passwords, tokens, and hidden fields are not collected. +
<% } %>