diff --git a/Makefile b/Makefile index 8744bda..1eeeae6 100644 --- a/Makefile +++ b/Makefile @@ -63,8 +63,10 @@ test: gen ## run tests for shared, server, and web format: ## format all gleam source gleam format $(GLEAM_DIRS) -check: ## verify all gleam source is formatted +check: ## verify gleam formatting and typecheck the web TypeScript sources gleam format --check $(GLEAM_DIRS) + cd web && bun install --frozen-lockfile + cd web && bun run typecheck deps-canary: ## fail if the newest allowed dependency horizon breaks gen or tests ./scripts/deps-canary.sh diff --git a/web/bun.lock b/web/bun.lock index 4cb7d6b..447deda 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -6,6 +6,9 @@ "dependencies": { "barcode-detector": "3.2.0", }, + "devDependencies": { + "typescript": "^5.6.0", + }, }, }, "packages": { @@ -17,6 +20,8 @@ "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "zxing-wasm": ["zxing-wasm@3.1.0", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.7.0" } }, "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA=="], } } diff --git a/web/package.json b/web/package.json index 5bbdde8..e3e94a9 100644 --- a/web/package.json +++ b/web/package.json @@ -3,5 +3,11 @@ "private": true, "dependencies": { "barcode-detector": "3.2.0" + }, + "devDependencies": { + "typescript": "^5.6.0" + }, + "scripts": { + "typecheck": "tsc --noEmit" } } diff --git a/web/src/at_record_web/barcode_detector.ts b/web/src/at_record_web/barcode_detector.ts new file mode 100644 index 0000000..f4e7b57 --- /dev/null +++ b/web/src/at_record_web/barcode_detector.ts @@ -0,0 +1,56 @@ +export interface DetectedBarcode { + readonly rawValue: string; +} + +export interface BarcodeDetectorInstance { + detect(source: CanvasImageSource | Blob): Promise; +} + +export interface BarcodeDetectorConstructor { + new (options?: { formats?: string[] }): BarcodeDetectorInstance; +} + +export interface ZXingPonyfill { + BarcodeDetector: BarcodeDetectorConstructor; + setZXingModuleOverrides(overrides: { + locateFile: (path: string) => string; + }): void; +} + +declare global { + interface Window { + BarcodeDetector?: BarcodeDetectorConstructor; + } +} + +// Chromium/Android has a native BarcodeDetector; everyone else lazily loads +// the zxing-wasm ponyfill (fetched from npm at build time, see `make vendor`) +// so only unsupported browsers pay for it. +let ponyfillPromise: Promise | null = null; + +export function loadBarcodeDetector(): Promise { + if (window.BarcodeDetector) { + return Promise.resolve(window.BarcodeDetector); + } + if (!ponyfillPromise) { + // Runtime-served path, not a build-time module: derive the specifier from + // window.location so esbuild can't fold it to a literal and try to resolve + // it at bundle time; it stays an external dynamic import fetched in-browser. + const vendorBase = `${window.location.origin}/static/vendor`; + ponyfillPromise = ( + import(`${vendorBase}/barcode-detector.js`) as Promise + ) + .then((mod) => { + mod.setZXingModuleOverrides({ + locateFile: (path) => `${vendorBase}/${path}`, + }); + return mod.BarcodeDetector; + }) + .catch((err) => { + // Reset the cache on rejection so a transient vendor-asset fetch failure retries next call instead of wedging scanning until reload. + ponyfillPromise = null; + throw err; + }); + } + return ponyfillPromise; +} diff --git a/web/src/at_record_web/browser.gleam b/web/src/at_record_web/browser.gleam index 7d2f9fc..c9125c1 100644 --- a/web/src/at_record_web/browser.gleam +++ b/web/src/at_record_web/browser.gleam @@ -1,39 +1,39 @@ //// Browser FFI: read and clear the URL query (to surface an OAuth `?error=`). -@external(javascript, "./ffi.mjs", "currentSearch") +@external(javascript, "./ffi.ts", "currentSearch") pub fn current_search() -> String -@external(javascript, "./ffi.mjs", "clearQuery") +@external(javascript, "./ffi.ts", "clearQuery") pub fn clear_query() -> Nil -@external(javascript, "./ffi.mjs", "applyTheme") +@external(javascript, "./ffi.ts", "applyTheme") pub fn apply_theme(theme: String) -> Nil -@external(javascript, "./ffi.mjs", "navigate") +@external(javascript, "./ffi.ts", "navigate") pub fn navigate(url: String) -> Nil -@external(javascript, "./ffi.mjs", "debounce") +@external(javascript, "./ffi.ts", "debounce") pub fn debounce(channel: String, ms: Int, callback: fn() -> Nil) -> Nil -@external(javascript, "./ffi.mjs", "onOutsideClick") +@external(javascript, "./ffi.ts", "onOutsideClick") pub fn on_outside_click(id: String, callback: fn() -> Nil) -> Nil -@external(javascript, "./ffi.mjs", "offOutsideClick") +@external(javascript, "./ffi.ts", "offOutsideClick") pub fn off_outside_click(id: String) -> Nil -@external(javascript, "./ffi.mjs", "uploadCover") +@external(javascript, "./ffi.ts", "uploadCover") pub fn upload_cover(url: String, callback: fn(Bool) -> Nil) -> Nil -@external(javascript, "./ffi.mjs", "startScanner") +@external(javascript, "./ffi.ts", "startScanner") pub fn start_scanner( on_detect: fn(String) -> Nil, on_unsupported: fn(String) -> Nil, ) -> Nil -@external(javascript, "./ffi.mjs", "stopScanner") +@external(javascript, "./ffi.ts", "stopScanner") pub fn stop_scanner() -> Nil /// Copy an absolute URL built from `path` (joined against the current /// origin) to the clipboard; `callback` carries whether the write worked. -@external(javascript, "./ffi.mjs", "copyToClipboard") +@external(javascript, "./ffi.ts", "copyToClipboard") pub fn copy_to_clipboard(path: String, callback: fn(Bool) -> Nil) -> Nil diff --git a/web/src/at_record_web/ffi.mjs b/web/src/at_record_web/ffi.mjs deleted file mode 100644 index d97a716..0000000 --- a/web/src/at_record_web/ffi.mjs +++ /dev/null @@ -1,180 +0,0 @@ -import { Result$Ok, Result$Error } from "../gleam.mjs"; - -export function currentSearch() { - return (globalThis.location && globalThis.location.search) || ""; -} - -export function clearQuery() { - if (globalThis.history && globalThis.location) { - globalThis.history.replaceState(null, "", globalThis.location.pathname); - } -} - -export function applyTheme(theme) { - const root = globalThis.document && globalThis.document.documentElement; - if (!root) return; - if (theme === "light" || theme === "dark") { - root.setAttribute("data-theme", theme); - } else { - root.removeAttribute("data-theme"); - } -} - -// A real full-page navigation, since modem would otherwise intercept a same-origin click. -export function navigate(url) { - if (globalThis.location) { - globalThis.location.assign(url); - } -} - -const debounceTimers = new Map(); -export function debounce(channel, ms, cb) { - clearTimeout(debounceTimers.get(channel)); - debounceTimers.set(channel, setTimeout(cb, ms)); -} - -// Capture phase so an outside click still fires cb even if a descendant -// stops propagation during bubbling. -const outsideClickHandlers = new Map(); -export function onOutsideClick(id, cb) { - offOutsideClick(id); - const handler = (event) => { - const el = document.getElementById(id); - if (el && !el.contains(event.target)) { - cb(); - } - }; - document.addEventListener("click", handler, true); - outsideClickHandlers.set(id, handler); -} - -export function offOutsideClick(id) { - const handler = outsideClickHandlers.get(id); - if (handler) { - document.removeEventListener("click", handler, true); - outsideClickHandlers.delete(id); - } -} - -export function uploadCover(url, cb) { - const input = document.getElementById("cover-file"); - const file = input && input.files && input.files[0]; - if (!file) { cb(false); return; } - const fd = new FormData(); - fd.append("cover", file); - fetch(url, { method: "POST", body: fd }) - .then((r) => cb(r.ok)) - .catch(() => cb(false)); -} - -let scanTimer = null; -let scanStream = null; - -// Cooldown so holding the same barcode in frame doesn't refire every tick. -let lastCode = ""; -let lastAt = 0; - -// Chromium/Android has a native BarcodeDetector; everyone else lazily loads -// the zxing-wasm ponyfill (fetched from npm at build time, see `make vendor`) -// so only unsupported browsers pay for it. -let ponyfillPromise = null; - -async function getBarcodeDetector() { - if ("BarcodeDetector" in window) { - return window.BarcodeDetector; - } - if (!ponyfillPromise) { - // Runtime-served path, not a build-time module: derive the specifier from - // window.location so esbuild can't fold it to a literal and try to resolve - // it at bundle time; it stays an external dynamic import fetched in-browser. - const vendorBase = `${window.location.origin}/static/vendor`; - ponyfillPromise = import(`${vendorBase}/barcode-detector.js`).then((mod) => { - mod.setZXingModuleOverrides({ - locateFile: (path) => `${vendorBase}/${path}`, - }); - return mod.BarcodeDetector; - }); - } - return ponyfillPromise; -} - -export function startScanner(onDetect, onUnsupported) { - getBarcodeDetector() - .then((BarcodeDetector) => { - const detector = new BarcodeDetector({ formats: ["ean_13", "upc_a"] }); - navigator.mediaDevices - .getUserMedia({ video: { facingMode: "environment" } }) - .then((stream) => { - scanStream = stream; - const video = document.getElementById("scan-video"); - if (!video) { - stream.getTracks().forEach((t) => t.stop()); - return; - } - video.srcObject = stream; - video.play(); - scanTimer = setInterval(() => { - detector - .detect(video) - .then((codes) => { - if (codes.length === 0) return; - const value = codes[0].rawValue; - const now = Date.now(); - if (value === lastCode && now - lastAt < 4000) return; - lastCode = value; - lastAt = now; - onDetect(value); - }) - .catch(() => {}); - }, 400); - }) - .catch(() => { - onUnsupported("Camera access was denied or unavailable."); - }); - }) - .catch(() => { - onUnsupported("Barcode scanning couldn't start in this browser."); - }); -} - -export function stopScanner() { - if (scanTimer) { - clearInterval(scanTimer); - scanTimer = null; - } - if (scanStream) { - scanStream.getTracks().forEach((t) => t.stop()); - scanStream = null; - } -} - -// Generic client-pref storage over localStorage. Both directions are -// best-effort: private browsing, a full quota, or a non-browser runtime just -// means a pref doesn't persist, never a thrown error. -export function prefsGet(key) { - try { - const value = globalThis.localStorage && globalThis.localStorage.getItem(key); - return value === null || value === undefined - ? Result$Error(undefined) - : Result$Ok(value); - } catch { - return Result$Error(undefined); - } -} - -export function prefsSet(key, value) { - try { - globalThis.localStorage && globalThis.localStorage.setItem(key, value); - } catch { - // best-effort - } -} - -export function copyToClipboard(path, cb) { - const url = (globalThis.location && globalThis.location.origin || "") + path; - if (!navigator.clipboard || !navigator.clipboard.writeText) { - cb(false); - return; - } - navigator.clipboard.writeText(url).then(() => cb(true)).catch(() => cb(false)); -} diff --git a/web/src/at_record_web/ffi.ts b/web/src/at_record_web/ffi.ts new file mode 100644 index 0000000..8b57df4 --- /dev/null +++ b/web/src/at_record_web/ffi.ts @@ -0,0 +1,170 @@ +import { loadBarcodeDetector } from "./barcode_detector.ts"; + +export function currentSearch(): string { + return globalThis.location?.search ?? ""; +} + +export function clearQuery(): void { + const { history, location } = globalThis; + if (!history || !location) { + return; + } + history.replaceState(null, "", location.pathname); +} + +export function applyTheme(theme: string): void { + const root = globalThis.document?.documentElement; + if (!root) { + return; + } + if (theme === "light" || theme === "dark") { + root.setAttribute("data-theme", theme); + return; + } + root.removeAttribute("data-theme"); +} + +// A real full-page navigation, since modem would otherwise intercept a same-origin click. +export function navigate(url: string): void { + if (globalThis.location) { + globalThis.location.assign(url); + } +} + +const debounceTimers = new Map>(); +export function debounce(channel: string, ms: number, cb: () => void): void { + clearTimeout(debounceTimers.get(channel)); + debounceTimers.set(channel, setTimeout(cb, ms)); +} + +// Capture phase so an outside click still fires cb even if a descendant +// stops propagation during bubbling. +const outsideClickHandlers = new Map void>(); +export function onOutsideClick(id: string, cb: () => void): void { + offOutsideClick(id); + const handler = (event: MouseEvent): void => { + const el = document.getElementById(id); + const target = event.target instanceof Node ? event.target : null; + if (el && !el.contains(target)) { + cb(); + } + }; + document.addEventListener("click", handler, true); + outsideClickHandlers.set(id, handler); +} + +export function offOutsideClick(id: string): void { + const handler = outsideClickHandlers.get(id); + if (handler) { + document.removeEventListener("click", handler, true); + outsideClickHandlers.delete(id); + } +} + +export function uploadCover(url: string, cb: (ok: boolean) => void): void { + const input = document.getElementById( + "cover-file", + ) as HTMLInputElement | null; + const file = input?.files?.[0]; + if (!file) { + cb(false); + return; + } + const fd = new FormData(); + fd.append("cover", file); + fetch(url, { method: "POST", body: fd }) + .then((r) => cb(r.ok)) + .catch(() => cb(false)); +} + +let scanTimer: ReturnType | null = null; +let scanStream: MediaStream | null = null; + +export function startScanner( + onDetect: (code: string) => void, + onUnsupported: (reason: string) => void, +): void { + stopScanner(); + loadBarcodeDetector() + .then((BarcodeDetector) => { + const detector = new BarcodeDetector({ formats: ["ean_13", "upc_a"] }); + navigator.mediaDevices + .getUserMedia({ video: { facingMode: "environment" } }) + .then((stream) => { + scanStream = stream; + const video = document.getElementById( + "scan-video", + ) as HTMLVideoElement | null; + if (!video) { + stream.getTracks().forEach((t) => t.stop()); + return; + } + video.srcObject = stream; + void video + .play() + .catch(() => + onUnsupported("Camera playback couldn't start in this browser."), + ); + scanTimer = setInterval(() => { + detector + .detect(video) + .then((codes) => { + const first = codes[0]; + if (!first) { + return; + } + onDetect(first.rawValue); + }) + .catch(() => {}); + }, 400); + }) + .catch(() => { + onUnsupported("Camera access was denied or unavailable."); + }); + }) + .catch(() => { + onUnsupported("Barcode scanning couldn't start in this browser."); + }); +} + +export function stopScanner(): void { + if (scanTimer) { + clearInterval(scanTimer); + scanTimer = null; + } + if (scanStream) { + scanStream.getTracks().forEach((t) => t.stop()); + scanStream = null; + } +} + +// Generic client-pref storage over localStorage. Both directions are +// best-effort: private browsing, a full quota, or a non-browser runtime just +// means a pref doesn't persist, never a thrown error. +export function prefsGet(key: string): string | null { + try { + return globalThis.localStorage?.getItem(key) ?? null; + } catch { + return null; + } +} + +export function prefsSet(key: string, value: string): void { + try { + globalThis.localStorage?.setItem(key, value); + } catch { + // best-effort + } +} + +export function copyToClipboard(path: string, cb: (ok: boolean) => void): void { + const url = (globalThis.location?.origin ?? "") + path; + if (!navigator.clipboard?.writeText) { + cb(false); + return; + } + navigator.clipboard + .writeText(url) + .then(() => cb(true)) + .catch(() => cb(false)); +} diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 5dfabed..0651907 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -465,7 +465,9 @@ pub fn default_scan_mode(live: Bool, photo: Bool) -> ScanMode { /// Barcode-scan session state. Rows are newest first; owned discogs ids are /// fetched once per session; `import_old_count` snapshots the crate size when -/// a batch run starts so the done page can show old -> new. +/// a batch run starts so the done page can show old -> new; `last_detected` +/// guards against the live loop or a re-shot photo firing the same code twice +/// in a row (e.g. it's still in frame right after its row was removed). pub type ScanState { ScanState( mode: ScanMode, @@ -477,6 +479,7 @@ pub type ScanState { importing: Bool, import_old_count: Int, import_added: Int, + last_detected: Option(String), ) } @@ -491,6 +494,7 @@ pub fn blank_scan() -> ScanState { importing: False, import_old_count: 0, import_added: 0, + last_detected: None, ) } diff --git a/web/src/at_record_web/photo_scan.gleam b/web/src/at_record_web/photo_scan.gleam index 0cf6034..ae047f3 100644 --- a/web/src/at_record_web/photo_scan.gleam +++ b/web/src/at_record_web/photo_scan.gleam @@ -1,5 +1,5 @@ //// Photo-mode barcode decode: one still frame, decoded once via the same -//// lazily-imported zxing-wasm ponyfill as ffi.mjs's live scanner. +//// lazily-imported zxing-wasm ponyfill as ffi.ts's live scanner. import gleam/list import lustre/effect.{type Effect} @@ -13,34 +13,34 @@ pub type DecodeError { } /// Native BarcodeDetector or the wasm ponyfill; False off-browser. -@external(javascript, "./photo_scan_ffi.mjs", "supportsLiveDetection") +@external(javascript, "./photo_scan_ffi.ts", "supportsLiveDetection") pub fn supports_live_detection() -> Bool /// Broader than live detection (any getUserMedia browser); False off-browser. -@external(javascript, "./photo_scan_ffi.mjs", "supportsPhotoCapture") +@external(javascript, "./photo_scan_ffi.ts", "supportsPhotoCapture") pub fn supports_photo_capture() -> Bool -@external(javascript, "./photo_scan_ffi.mjs", "startCamera") +@external(javascript, "./photo_scan_ffi.ts", "startCamera") fn start_camera_ffi(video_id: String, on_error: fn(String) -> Nil) -> Nil -@external(javascript, "./photo_scan_ffi.mjs", "stopCamera") +@external(javascript, "./photo_scan_ffi.ts", "stopCamera") fn stop_camera_ffi() -> Nil -@external(javascript, "./photo_scan_ffi.mjs", "captureFrameEffect") +@external(javascript, "./photo_scan_ffi.ts", "captureFrameEffect") fn capture_frame_ffi( video_id: String, on_result: fn(Frame) -> Nil, on_error: fn(String, String) -> Nil, ) -> Nil -@external(javascript, "./photo_scan_ffi.mjs", "decodeBarcodesEffect") +@external(javascript, "./photo_scan_ffi.ts", "decodeBarcodesEffect") fn decode_barcodes_ffi( frame: Frame, on_result: fn(List(String)) -> Nil, on_error: fn(String, String) -> Nil, ) -> Nil -@external(javascript, "./photo_scan_ffi.mjs", "captureAndDecode") +@external(javascript, "./photo_scan_ffi.ts", "captureAndDecode") fn capture_and_decode_ffi( video_id: String, on_result: fn(List(String)) -> Nil, diff --git a/web/src/at_record_web/photo_scan_ffi.mjs b/web/src/at_record_web/photo_scan_ffi.ts similarity index 52% rename from web/src/at_record_web/photo_scan_ffi.mjs rename to web/src/at_record_web/photo_scan_ffi.ts index 309e63a..6b5def9 100644 --- a/web/src/at_record_web/photo_scan_ffi.mjs +++ b/web/src/at_record_web/photo_scan_ffi.ts @@ -1,45 +1,34 @@ -// Separate promise from ffi.mjs on purpose: both import the same URL, and the browser module cache dedupes the fetch. -let ponyfillPromise = null; - -function getBarcodeDetector() { - if (!ponyfillPromise) { - const vendorBase = `${window.location.origin}/static/vendor`; - ponyfillPromise = import(`${vendorBase}/barcode-detector.js`).then((mod) => { - mod.setZXingModuleOverrides({ - locateFile: (path) => `${vendorBase}/${path}`, - }); - return mod.BarcodeDetector; - }); - } - return ponyfillPromise; -} +import { loadBarcodeDetector } from "./barcode_detector.ts"; // typeof guards keep both probes callable from the node-run test suite, where they report false. -export function supportsPhotoCapture() { +export function supportsPhotoCapture(): boolean { if (typeof navigator === "undefined" || typeof document === "undefined") { return false; } - return !!( - navigator.mediaDevices && - navigator.mediaDevices.getUserMedia && - document.createElement("canvas").toBlob + return ( + typeof navigator.mediaDevices?.getUserMedia === "function" && + "toBlob" in HTMLCanvasElement.prototype ); } -export function supportsLiveDetection() { +export function supportsLiveDetection(): boolean { if (typeof window === "undefined") { return false; } - if ("BarcodeDetector" in window) { + if (window.BarcodeDetector) { return true; } // Live scanning falls back to the vendored zxing-wasm ponyfill, so it only needs a camera plus wasm. return typeof WebAssembly === "object" && supportsPhotoCapture(); } -let cameraStream = null; +let cameraStream: MediaStream | null = null; -export function startCamera(videoId, onError) { +export function startCamera( + videoId: string, + onError: (reason: string) => void, +): void { + stopCamera(); if (!supportsPhotoCapture()) { onError("Camera capture isn't supported in this browser."); return; @@ -47,19 +36,21 @@ export function startCamera(videoId, onError) { navigator.mediaDevices .getUserMedia({ video: { facingMode: "environment" } }) .then((stream) => { - const video = document.getElementById(videoId); + const video = document.getElementById(videoId) as HTMLVideoElement | null; if (!video) { stream.getTracks().forEach((t) => t.stop()); return; } cameraStream = stream; video.srcObject = stream; - video.play(); + void video + .play() + .catch(() => onError("Camera playback couldn't start in this browser.")); }) .catch(() => onError("Camera access was denied or unavailable.")); } -export function stopCamera() { +export function stopCamera(): void { if (!cameraStream) { return; } @@ -67,11 +58,14 @@ export function stopCamera() { cameraStream = null; } -function captureFrame(videoId) { - return new Promise((resolve, reject) => { - const video = document.getElementById(videoId); +function captureFrame(videoId: string): Promise { + return new Promise((resolve, reject) => { + const video = document.getElementById(videoId) as HTMLVideoElement | null; if (!video || !video.videoWidth || !video.videoHeight) { - reject({ code: "capture_failed", message: "The camera has no frame ready yet." }); + reject({ + code: "capture_failed", + message: "The camera has no frame ready yet.", + }); return; } const canvas = document.createElement("canvas"); @@ -79,7 +73,10 @@ function captureFrame(videoId) { canvas.height = video.videoHeight; const ctx = canvas.getContext("2d"); if (!ctx) { - reject({ code: "capture_failed", message: "Canvas isn't available in this browser." }); + reject({ + code: "capture_failed", + message: "Canvas isn't available in this browser.", + }); return; } ctx.drawImage(video, 0, 0, canvas.width, canvas.height); @@ -87,9 +84,12 @@ function captureFrame(videoId) { (blob) => { if (blob) { resolve(blob); - } else { - reject({ code: "capture_failed", message: "Couldn't capture a still frame." }); + return; } + reject({ + code: "capture_failed", + message: "Couldn't capture a still frame.", + }); }, "image/jpeg", 0.92, @@ -97,44 +97,55 @@ function captureFrame(videoId) { }); } -function decodeBarcodes(blob) { - return getBarcodeDetector() +function decodeBarcodes(blob: Blob): Promise { + return loadBarcodeDetector() .then((BarcodeDetector) => { const detector = new BarcodeDetector({ formats: ["ean_13", "upc_a"] }); return detector.detect(blob); }) .then((codes) => codes.map((c) => c.rawValue)) - .catch((err) => { - throw { - code: "decode_failed", - message: err && err.message ? err.message : "Barcode decoding failed.", - }; + .catch((err: unknown) => { + const [, message] = errorParts(err); + throw { code: "decode_failed", message }; }); } -function errorParts(err) { - return [err && err.code ? err.code : "decode_failed", err && err.message ? err.message : String(err)]; +function errorParts(err: unknown): [string, string] { + const e = err as { code?: string; message?: string } | null; + return [e?.code || "decode_failed", e?.message || String(err)]; } -export function captureFrameEffect(videoId, onResult, onError) { +export function captureFrameEffect( + videoId: string, + onResult: (frame: Blob) => void, + onError: (code: string, message: string) => void, +): void { captureFrame(videoId) .then((blob) => onResult(blob)) - .catch((err) => { + .catch((err: unknown) => { const [code, message] = errorParts(err); onError(code, message); }); } -export function decodeBarcodesEffect(blob, onResult, onError) { +export function decodeBarcodesEffect( + blob: Blob, + onResult: (codes: string[]) => void, + onError: (code: string, message: string) => void, +): void { decodeBarcodes(blob) .then((codes) => onResult(codes)) - .catch((err) => { + .catch((err: unknown) => { const [code, message] = errorParts(err); onError(code, message); }); } -export function captureAndDecode(videoId, onResult, onError) { +export function captureAndDecode( + videoId: string, + onResult: (codes: string[]) => void, + onError: (code: string, message: string) => void, +): void { if (!supportsPhotoCapture()) { onError("unsupported", "Photo capture isn't supported in this browser."); return; @@ -142,7 +153,7 @@ export function captureAndDecode(videoId, onResult, onError) { captureFrame(videoId) .then((blob) => decodeBarcodes(blob)) .then((codes) => onResult(codes)) - .catch((err) => { + .catch((err: unknown) => { const [code, message] = errorParts(err); onError(code, message); }); diff --git a/web/src/at_record_web/prefs.gleam b/web/src/at_record_web/prefs.gleam index db53b81..330566d 100644 --- a/web/src/at_record_web/prefs.gleam +++ b/web/src/at_record_web/prefs.gleam @@ -5,13 +5,15 @@ //// runtime) just means a preference silently doesn't persist, never a //// crash - callers always have a sensible default to fall back to. +import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode import gleam/json +import gleam/result -@external(javascript, "./ffi.mjs", "prefsGet") -fn ffi_get(key: String) -> Result(String, Nil) +@external(javascript, "./ffi.ts", "prefsGet") +fn ffi_get(key: String) -> Dynamic -@external(javascript, "./ffi.mjs", "prefsSet") +@external(javascript, "./ffi.ts", "prefsSet") fn ffi_set(key: String, value: String) -> Nil /// The crate page's grid/rows toggle. @@ -31,6 +33,8 @@ fn namespaced(key: String) -> String { /// unavailable" - callers fall back to a default either way. pub fn get(key: String) -> Result(String, Nil) { ffi_get(namespaced(key)) + |> decode.run(decode.string) + |> result.replace_error(Nil) } /// Persist a preference by its bare key. Fire-and-forget: wrap in an diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 49064e3..106ad62 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -691,19 +691,27 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) // A stale seen-ids set just means new matches won't show "already owned" until the next fetch. GotScanSeen(Error(_)) -> #(model, effect.none()) + // The same code repeated back-to-back (still in frame, or a re-shot photo) + // is dropped outright; a different code always lands, even mid-import. BarcodeDetected(barcode) -> - case list.any(model.scan.rows, fn(r) { r.barcode == barcode }) { + case Some(barcode) == model.scan.last_detected { True -> #(model, effect.none()) - False -> #( - Model( - ..model, - scan: model.ScanState(..model.scan, rows: [ - model.ScanRow(barcode:, outcome: model.Pending), - ..model.scan.rows - ]), - ), - effects.scan_lookup(barcode), - ) + False -> { + let scan = model.ScanState(..model.scan, last_detected: Some(barcode)) + case list.any(scan.rows, fn(r) { r.barcode == barcode }) { + True -> #(Model(..model, scan:), effect.none()) + False -> #( + Model( + ..model, + scan: model.ScanState(..scan, rows: [ + model.ScanRow(barcode:, outcome: model.Pending), + ..scan.rows + ]), + ), + effects.scan_lookup(barcode), + ) + } + } } GotScanResult(barcode, Ok(lookup)) -> #( Model( @@ -726,7 +734,12 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { False -> #( Model( ..model, - scan: model.ScanState(..model.scan, mode:, photo_hint: None), + scan: model.ScanState( + ..model.scan, + mode:, + photo_hint: None, + last_detected: None, + ), ), effect.batch([stop_capture(model.scan.mode), start_capture(mode)]), ) @@ -757,6 +770,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ..model.scan, mode: model.LiveMode, photo_supported: False, + last_detected: None, ), ), effects.start_scanner(), @@ -1591,6 +1605,8 @@ fn scan_for_route( route: model.Route, ) -> model.ScanState { case route { + // Capture (re)starts unconditionally on entry, so the previous session's + // last-seen code must never carry over and jam the fresh detector loop. Scan -> case previous { ScanReview -> scan @@ -1602,6 +1618,7 @@ fn scan_for_route( ) _ -> model.blank_scan() } + |> fn(s) { model.ScanState(..s, last_detected: None) } |> arm_capture_modes(previous) _ -> scan } diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index 347292b..35beba3 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -217,7 +217,11 @@ pub fn loading_shelf_renders_skeleton_cards_matching_the_chosen_display_test() { pub fn masthead_shows_only_the_wordmark_on_hero_less_routes_test() { let seeded = - Model(..logged_in(), route: Record("e1"), shelf: ShelfLoaded([an_entry()])) + set_crate( + Model(..logged_in(), route: Record("e1")), + OwnCrate, + ShelfLoaded(Own, [an_entry()]), + ) let html = view.view(seeded) |> element.to_string assert string.contains( html, diff --git a/web/test/scan_photo_test.gleam b/web/test/scan_photo_test.gleam index 95ce6a0..5282ba6 100644 --- a/web/test/scan_photo_test.gleam +++ b/web/test/scan_photo_test.gleam @@ -119,6 +119,18 @@ pub fn switching_mode_clears_the_hint_and_restarts_capture_test() { assert effect != empty_effect() } +// The fresh detector loop on the other side of the switch must not inherit +// a stale last-seen code from the mode it's leaving. +pub fn switching_mode_clears_the_last_detected_code_test() { + let seeded = + Model( + ..photo_model(), + scan: ScanState(..photo_model().scan, last_detected: Some("0123456789")), + ) + let #(model, _) = update(seeded, SetScanMode(LiveMode)) + assert model.scan.last_detected == None +} + pub fn switching_to_the_current_mode_is_a_no_op_test() { let seeded = photo_model() let #(model, effect) = update(seeded, SetScanMode(PhotoMode)) @@ -148,3 +160,16 @@ pub fn returning_from_review_keeps_the_toggled_mode_test() { let #(model, _) = update(seeded, OnRouteChange(Scan)) assert model.scan.mode == PhotoMode } + +// Capture always restarts fresh on entry, so a code left over from the +// session before ScanReview must not jam the new detector loop. +pub fn returning_from_review_clears_the_last_detected_code_test() { + let seeded = + Model( + ..logged_in(), + route: ScanReview, + scan: ScanState(..blank_scan(), last_detected: Some("0123456789")), + ) + let #(model, _) = update(seeded, OnRouteChange(Scan)) + assert model.scan.last_detected == None +} diff --git a/web/test/scan_test.gleam b/web/test/scan_test.gleam index c902afe..815fdf1 100644 --- a/web/test/scan_test.gleam +++ b/web/test/scan_test.gleam @@ -35,6 +35,62 @@ pub fn barcode_detected_ignores_already_scanned_code_test() { assert model.scan.rows == seeded.scan.rows } +pub fn barcode_detected_records_the_code_as_last_detected_test() { + let #(model, _) = update(logged_in(), BarcodeDetected("0123456789")) + assert model.scan.last_detected == Some("0123456789") +} + +// The live loop and a re-shot photo both funnel through BarcodeDetected; +// a repeat of the immediately-preceding code is dropped outright, even +// after its row was removed (still in frame right after a RemoveScanRow). +pub fn barcode_detected_drops_a_consecutive_repeat_of_the_last_code_test() { + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), last_detected: Some("0123456789")), + ) + let #(model, effect) = update(seeded, BarcodeDetected("0123456789")) + assert model.scan.rows == [] + assert effect == empty_effect() +} + +pub fn barcode_detected_accepts_a_different_code_after_a_repeat_test() { + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), last_detected: Some("0123456789")), + ) + let #(model, effect) = update(seeded, BarcodeDetected("9876543210")) + assert model.scan.rows == [ScanRow(barcode: "9876543210", outcome: Pending)] + assert model.scan.last_detected == Some("9876543210") + assert effect != empty_effect() +} + +// The scenario the dedup exists for: a barcode is scanned, its row is +// dismissed, but the same physical barcode is still sitting in frame. +// Without the guard the very next tick would silently re-add it. +pub fn removed_row_does_not_reappear_while_the_same_code_stays_in_frame_test() { + let #(scanned, _) = update(logged_in(), BarcodeDetected("aaa")) + let #(removed, _) = update(scanned, RemoveScanRow("aaa")) + let #(model, effect) = update(removed, BarcodeDetected("aaa")) + assert model.scan.rows == [] + assert effect == empty_effect() +} + +// Once a different code has been seen, the original is no longer "the last +// one" and scans normally again, even if its row was previously removed. +pub fn a_removed_code_scans_again_once_a_different_code_intervened_test() { + let #(scanned, _) = update(logged_in(), BarcodeDetected("aaa")) + let #(removed, _) = update(scanned, RemoveScanRow("aaa")) + let #(seen_other, _) = update(removed, BarcodeDetected("bbb")) + let #(model, _) = update(seen_other, BarcodeDetected("aaa")) + assert model.scan.rows + == [ + ScanRow(barcode: "aaa", outcome: Pending), + ScanRow(barcode: "bbb", outcome: Pending), + ] +} + pub fn got_scan_result_marks_owned_from_seen_ids_test() { let seeded = Model( diff --git a/web/test/support.gleam b/web/test/support.gleam index aef37fa..b770349 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -90,7 +90,7 @@ pub fn rendered_loaded_crate(display: Display) -> String { } pub fn rendered_loading_crate(display: Display) -> String { - Model(..logged_in(), display:, shelf: ShelfLoading) + set_crate(Model(..logged_in(), display:), OwnCrate, ShelfLoading) |> crate.view |> element.to_string } diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..271efd2 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}