diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx
index c917925..19231f0 100644
--- a/app/components/TopBar.tsx
+++ b/app/components/TopBar.tsx
@@ -11,6 +11,7 @@ import { useProgress } from './shell/Progress'
import { AltGlyph, Button, IconButton, Tag, cx } from './ui'
import { SavedIndicator } from './shell/SavedIndicator'
import { ThemeToggle } from './shell/ThemeToggle'
+import { FeedbackButton } from './feedback/Feedback'
// The primary action follows the first step not done yet; Export takes over at the end.
const NEXT_ACTION: Partial {description} {howTo}
+ Feedback. The Feedback form does not send anything
+ itself: it opens GitHub in a new tab with your text filled in, so GitHub receives that text, and the app
+ version and browser if you leave them ticked, when its page loads. Nothing is published until you submit it
+ there, under{' '}
+
Images you link. If you add an image address to an alternative, attribute or level, your browser loads the image from that site, which can then see your IP diff --git a/lib/__tests__/feedback.test.ts b/lib/__tests__/feedback.test.ts new file mode 100644 index 0000000..7f45aec --- /dev/null +++ b/lib/__tests__/feedback.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { MAX_MESSAGE, describeBrowser, feedbackUrl } from '../feedback' + +const UA = { + firefoxMac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.6; rv:131.0) Gecko/20100101 Firefox/131.0', + chromeWin: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36', + edge: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.79', + safariIos: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', +} + +const params = (url: string) => new URL(url).searchParams + +describe('describeBrowser', () => { + it('names common browsers and systems', () => { + expect(describeBrowser(UA.firefoxMac)).toBe('Firefox 131 on macOS') + expect(describeBrowser(UA.chromeWin)).toBe('Chrome 129 on Windows') + expect(describeBrowser(UA.edge)).toBe('Edge 129 on Windows') + expect(describeBrowser(UA.safariIos)).toBe('Safari 18 on iOS') + expect(describeBrowser('')).toBe('Unknown browser') + }) +}) + +describe('feedbackUrl', () => { + it('fills the bug report form, with context only when asked', () => { + const p = params(feedbackUrl({ kind: 'bug', title: 'QSF import fails', message: 'Error on import', context: { version: '1.2.0', browser: 'Firefox 131 on macOS' } })) + expect(p.get('template')).toBe('bug_report.yml') + expect(p.get('title')).toBe('QSF import fails') + expect(p.get('what')).toBe('Error on import') + expect(p.get('browser')).toBe('Firefox 131 on macOS') + expect(p.get('version')).toBe('v1.2.0') + const bare = params(feedbackUrl({ kind: 'bug', title: 'x', message: 'y' })) + expect(bare.has('browser')).toBe(false) + }) + it('fills the feature request form', () => { + const p = params(feedbackUrl({ kind: 'idea', title: 'Attribute library', message: 'Travel time presets' })) + expect(p.get('template')).toBe('feature_request.yml') + expect(p.get('need')).toBe('Travel time presets') + }) + it('opens a labelled blank issue for questions', () => { + const p = params(feedbackUrl({ kind: 'question', title: 'Priors?', message: 'How?', context: { version: '1.2.0', browser: 'Chrome 129 on Windows' } })) + expect(p.get('labels')).toBe('question') + expect(p.get('body')).toContain('How?') + expect(p.get('body')).toContain('UtilityLab v1.2.0 · Chrome 129 on Windows') + }) + it('shortens long messages so the link stays usable', () => { + const url = feedbackUrl({ kind: 'idea', title: 't', message: 'a'.repeat(MAX_MESSAGE + 500) }) + expect(params(url).get('need')!.endsWith('[shortened to fit the link]')).toBe(true) + expect(url.length).toBeLessThan(8000) + }) +}) diff --git a/lib/feedback.ts b/lib/feedback.ts new file mode 100644 index 0000000..563aae5 --- /dev/null +++ b/lib/feedback.ts @@ -0,0 +1,66 @@ +// Feedback goes to GitHub as a pre-filled issue. Nothing is sent from the app: the link opens +// GitHub's issue form, filled in, and the person submits it there. + +export const REPO_ISSUES = 'https://github.com/acesonic7/utilitylab/issues/new' + +export type FeedbackKind = 'bug' | 'idea' | 'question' + +export type FeedbackInput = { + kind: FeedbackKind + title: string + message: string + // Added only when the person ticks "include app version and browser". + context?: { version: string; browser: string } +} + +// Keeps the link well under the length browsers and GitHub accept. +export const MAX_MESSAGE = 3000 + +// A short "Firefox 131 on macOS" from a user agent string. +export function describeBrowser(ua: string): string { + const version = (re: RegExp) => ua.match(re)?.[1]?.split('.')[0] + let browser = 'Unknown browser' + if (/Edg\//.test(ua)) browser = `Edge ${version(/Edg\/([\d.]+)/)}` + else if (/OPR\//.test(ua)) browser = `Opera ${version(/OPR\/([\d.]+)/)}` + else if (/Firefox\//.test(ua)) browser = `Firefox ${version(/Firefox\/([\d.]+)/)}` + else if (/Chrome\//.test(ua)) browser = `Chrome ${version(/Chrome\/([\d.]+)/)}` + else if (/Safari\//.test(ua) && /Version\//.test(ua)) browser = `Safari ${version(/Version\/([\d.]+)/)}` + let os = '' + if (/iPhone|iPad|iPod/.test(ua)) os = 'iOS' + else if (/Android/.test(ua)) os = 'Android' + else if (/Mac OS X|Macintosh/.test(ua)) os = 'macOS' + else if (/Windows/.test(ua)) os = 'Windows' + else if (/CrOS/.test(ua)) os = 'ChromeOS' + else if (/Linux/.test(ua)) os = 'Linux' + return os ? `${browser} on ${os}` : browser +} + +function clip(text: string): string { + const t = text.trim() + return t.length > MAX_MESSAGE ? `${t.slice(0, MAX_MESSAGE)}\n\n[shortened to fit the link]` : t +} + +export function feedbackUrl(input: FeedbackInput): string { + const params = new URLSearchParams() + const title = input.title.trim() + const message = clip(input.message) + if (input.kind === 'bug') { + params.set('template', 'bug_report.yml') + if (title) params.set('title', title) + if (message) params.set('what', message) + if (input.context) { + params.set('browser', input.context.browser) + params.set('version', `v${input.context.version}`) + } + } else if (input.kind === 'idea') { + params.set('template', 'feature_request.yml') + if (title) params.set('title', title) + if (message) params.set('need', message) + } else { + params.set('labels', 'question') + if (title) params.set('title', title) + const context = input.context ? `\n\n---\nUtilityLab v${input.context.version} · ${input.context.browser}` : '' + if (message || context) params.set('body', `${message}${context}`) + } + return `${REPO_ISSUES}?${params.toString()}` +}