From a1f50222882f5655a9b9f162be3fcf1c50ab9210 Mon Sep 17 00:00:00 2001 From: Eric Gade Date: Mon, 8 Aug 2022 16:34:38 -0400 Subject: [PATCH 01/14] Updating types to deal with new native camera attempts -- What With the addition of new checks for `maxAttemptsBeforeNativeCamera`, which triggers the use of the native camera after a certain number of failed Acuant attempts, we needed to update some of the type definitions. In addition to doing so, we have converted several of the affected files to full TypeScript (from JSDoc annotations). --- ...{acuant-capture.jsx => acuant-capture.tsx} | 299 +++++++++--------- .../context/failed-capture-attempts.tsx | 97 ++++++ 2 files changed, 253 insertions(+), 143 deletions(-) rename app/javascript/packages/document-capture/components/{acuant-capture.jsx => acuant-capture.tsx} (74%) create mode 100644 app/javascript/packages/document-capture/context/failed-capture-attempts.tsx diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx similarity index 74% rename from app/javascript/packages/document-capture/components/acuant-capture.jsx rename to app/javascript/packages/document-capture/components/acuant-capture.tsx index 9fca4c40e51..418b457ee30 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -6,6 +6,7 @@ import { useMemo, useEffect, useImperativeHandle, + Ref, } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; import { useIfStillMounted, useDidUpdateEffect } from '@18f/identity-react-hooks'; @@ -20,81 +21,110 @@ import DeviceContext from '../context/device'; import UploadContext from '../context/upload'; import useCounter from '../hooks/use-counter'; import useCookie from '../hooks/use-cookie'; +import type { ReactNode, MouseEvent } from 'react'; +import type { AcuantSuccessResponse } from './acuant-camera'; +import type { AcuantDocumentType } from './acuant-camera'; +import type { AcuantCaptureFailureError } from './acuant-camera'; +import type { FullScreenRefHandle } from '@18f/identity-components'; +import type { FocusTrap } from 'focus-trap'; + +type AcuantDocumentTypeLabel = 'id' | 'passport' | 'none'; +type AcuantImageAssessment = 'success' | 'glare' | 'blurry'; +type ImageSource = 'acuant' | 'upload'; + +interface ImageAnalyticsPayload { + /** + * Image width, or null if unknown + */ + width?: number | null; + /** + * Image height, or null if unknown + */ + height?: number | null; + /** + * Mime type, or null if unknown + */ + mimeType?: string | null; + /** + * Method by which the image was added + */ + source: ImageSource; + /** + * Total number of attempts at this point + */ + attempt?: number; + /** + * Size of the image in bytes + */ + size: number; +} -/** @typedef {import('react').ReactNode} ReactNode */ -/** @typedef {import('./acuant-camera').AcuantSuccessResponse} AcuantSuccessResponse */ -/** @typedef {import('./acuant-camera').AcuantDocumentType} AcuantDocumentType */ -/** @typedef {import('@18f/identity-components').FullScreenRefHandle} FullScreenRefHandle */ -/** @typedef {import('../context/acuant').AcuantGlobal} AcuantGlobal */ - -/** - * @typedef {"id"|"passport"|"none"} AcuantDocumentTypeLabel - */ - -/** - * @typedef {"success"|"glare"|"blurry"} AcuantImageAssessment - */ - -/** - * @typedef {"acuant"|"upload"} ImageSource - */ - -/** - * @typedef ImageAnalyticsPayload - * - * @prop {number?} width Image width, or null if unknown. - * @prop {number?} height Image height, or null if unknown. - * @prop {string?} mimeType Mime type, or null if unknown. - * @prop {ImageSource} source Method by which image was added. - * @prop {number=} attempt Total number of attempts at this point. - * @prop {number} size Size of image, in bytes. - */ - -/** - * @typedef _AcuantImageAnalyticsPayload - * - * @prop {AcuantDocumentTypeLabel} documentType - * @prop {number} dpi - * @prop {number} moire - * @prop {number} glare - * @prop {number} glareScoreThreshold - * @prop {boolean} isAssessedAsGlare - * @prop {number} sharpness - * @prop {number} sharpnessScoreThreshold - * @prop {boolean} isAssessedAsBlurry - * @prop {AcuantImageAssessment} assessment - */ +interface _AcuantImageAnalyticsPayload { + documentType: AcuantDocumentTypeLabel; + dpi: number; + moire: number; + glare: number; + glareScoreThreshold: number; + isAssessedAsGlare: boolean; + sharpness: number; + sharpnessScoreThreshold: number; + isAssessedAsBlurry: boolean; + assessment: AcuantImageAssessment; +} -/** - * @typedef {ImageAnalyticsPayload & _AcuantImageAnalyticsPayload} AcuantImageAnalyticsPayload - */ +type AcuantImageAnalyticsPayload = ImageAnalyticsPayload & _AcuantImageAnalyticsPayload; -/** - * @typedef AcuantCaptureProps - * - * @prop {string} label Label associated with file input. - * @prop {string=} bannerText Optional banner text to show in file input. - * @prop {string|Blob|null|undefined} value Current value. - * @prop {( - * nextValue: string|Blob|null, - * metadata?: ImageAnalyticsPayload - * )=>void} onChange Callback receiving next value on change. - * @prop {()=>void=} onCameraAccessDeclined Camera permission declined callback. - * @prop {'user'|'environment'=} capture Facing mode of capture. If capture is not specified and a - * camera is supported, defaults to the Acuant environment camera capture. - * @prop {string=} className Optional additional class names. - * @prop {boolean=} allowUpload Whether to allow file upload. Defaults to `true`. - * @prop {ReactNode=} errorMessage Error to show. - * @prop {string} name Prefix to prepend to user action analytics labels. - */ +interface AcuantCaptureProps { + /** + * Label associated with file input + */ + label: string; + /** + * Optional banner text to show in file input + */ + bannerText: string; + /** + * Current value + */ + value: string | Blob | null | undefined; + /** + * Callback receiving next value on change + */ + onChange: (nextValue: string | Blob | null, metadata?: ImageAnalyticsPayload) => void; + /** + * Camera permission declined callback + */ + onCameraAccessDeclined?: () => void; + /** + * Facing mode of caopture. If capture is not + * specified and a camera is supported, defaults + * to the Acuant environment camera capture. + */ + capture: 'user' | 'environment'; + /** + * Optional additional class names + */ + className?: string; + /** + * Whether to allow file upload. Defaults + * to true. + */ + allowUpload?: boolean; + /** + * Error message to show + */ + errorMessage: ReactNode; + /** + * Prefix to prepend to user action analytics labels. + */ + name: string; +} /** * Non-breaking space (` `) represented as unicode escape sequence, which React will more * happily tolerate than an HTML entity. - * - * @type {string} */ -const NBSP_UNICODE = '\u00A0'; +const NBSP_UNICODE: string = '\u00A0'; /** * A noop function. @@ -104,21 +134,15 @@ const noop = () => {}; /** * Returns true if the given Acuant capture failure was caused by the user declining access to the * camera, or false otherwise. - * - * @param {import('./acuant-camera').AcuantCaptureFailureError} error - * - * @return {boolean} */ -export const isAcuantCameraAccessFailure = (error) => error instanceof Error; +export const isAcuantCameraAccessFailure = (error: AcuantCaptureFailureError) => + error instanceof Error; /** * Returns a human-readable document label corresponding to the given document type constant. * - * @param {AcuantDocumentType} documentType - * - * @return {AcuantDocumentTypeLabel} Human-readable document label. */ -function getDocumentTypeLabel(documentType) { +function getDocumentTypeLabel(documentType: AcuantDocumentType): AcuantDocumentTypeLabel { switch (documentType) { case 1: return 'id'; @@ -129,19 +153,16 @@ function getDocumentTypeLabel(documentType) { } } -/** - * @param {import('./acuant-camera').AcuantCaptureFailureError} error - * @param {string=} code - * - * @return {string} - */ -export function getNormalizedAcuantCaptureFailureMessage(error, code) { +export function getNormalizedAcuantCaptureFailureMessage( + error: AcuantCaptureFailureError, + code: string | undefined, +): string { if (isAcuantCameraAccessFailure(error)) { return 'User or system denied camera access'; } - const { REPEAT_FAIL_CODE, SEQUENCE_BREAK_CODE } = /** @type {AcuantGlobal} */ (window) - .AcuantJavascriptWebSdk; + const { REPEAT_FAIL_CODE, SEQUENCE_BREAK_CODE } = + /** @type {AcuantGlobal} */ window.AcuantJavascriptWebSdk; switch (code) { case REPEAT_FAIL_CODE: @@ -168,15 +189,10 @@ export function getNormalizedAcuantCaptureFailureMessage(error, code) { } } -/** - * @param {File} file Image file. - * - * @return {Promise<{width: number?, height: number?}>} - */ -function getImageDimensions(file) { - let objectURL; +function getImageDimensions(file: File): Promise<{ width: number | null; height: number | null }> { + let objectURL: string; return file.type.indexOf('image/') === 0 - ? new Promise((resolve) => { + ? new Promise<{ width: number | null; height: number | null }>((resolve) => { objectURL = window.URL.createObjectURL(file); const image = new window.Image(); image.onload = () => resolve({ width: image.width, height: image.height }); @@ -196,9 +212,8 @@ function getImageDimensions(file) { * tick, the focus trap's deactivation will be overridden to prevent any default focus return, in * order to avoid a race condition between the intended focus targets. * - * @param {import('focus-trap').FocusTrap} focusTrap */ -function suspendFocusTrapForAnticipatedFocus(focusTrap) { +function suspendFocusTrapForAnticipatedFocus(focusTrap: FocusTrap) { // Pause trap event listeners to prevent focus from being pulled back into the trap container in // response to programmatic focus transitions. focusTrap.pause(); @@ -225,7 +240,7 @@ function suspendFocusTrapForAnticipatedFocus(focusTrap) { }, 0); } -export function getDecodedBase64ByteSize(data) { +export function getDecodedBase64ByteSize(data: string | any[]) { let bytes = 0.75 * data.length; let i = data.length; @@ -239,8 +254,6 @@ export function getDecodedBase64ByteSize(data) { /** * Returns an element serving as an enhanced FileInput, supporting direct capture using Acuant SDK * in supported devices. - * - * @param {AcuantCaptureProps} props Props object. */ function AcuantCapture( { @@ -254,8 +267,8 @@ function AcuantCapture( allowUpload = true, errorMessage, name, - }, - ref, + }: AcuantCaptureProps, + ref: Ref, ) { const { isReady, @@ -268,12 +281,12 @@ function AcuantCapture( } = useContext(AcuantContext); const { isMockClient } = useContext(UploadContext); const { addPageAction } = useContext(AnalyticsContext); - const fullScreenRef = useRef(/** @type {FullScreenRefHandle?} */ (null)); - const inputRef = useRef(/** @type {?HTMLInputElement} */ (null)); + const fullScreenRef = useRef(null); + const inputRef = useRef(null); const isForceUploading = useRef(false); const isSuppressingClickLogging = useRef(false); const [isCapturingEnvironment, setIsCapturingEnvironment] = useState(false); - const [ownErrorMessage, setOwnErrorMessage] = useState(/** @type {?string} */ (null)); + const [ownErrorMessage, setOwnErrorMessage] = useState(null); const [hasStartedCropping, setHasStartedCropping] = useState(false); const ifStillMounted = useIfStillMounted(); useMemo(() => setOwnErrorMessage(null), [value]); @@ -304,25 +317,19 @@ function AcuantCapture( /** * Calls onChange with next value and resets any errors which may be present. - * - * @param {Blob|string|null} nextValue Next value. - * @param {ImageAnalyticsPayload=} metadata Capture metadata. */ - function onChangeAndResetError(nextValue, metadata) { + function onChangeAndResetError( + nextValue: Blob | string | null, + metadata?: ImageAnalyticsPayload | undefined, + ) { setOwnErrorMessage(null); onChange(nextValue, metadata); } /** * Returns an analytics payload, decorated with common values. - * - * @template {ImageAnalyticsPayload|AcuantImageAnalyticsPayload} P - * - * @param {P} payload - * - * @return {P} */ - function getAddAttemptAnalyticsPayload(payload) { + function getAddAttemptAnalyticsPayload

(payload: P): P { const enhancedPayload = { ...payload, attempt }; incrementAttempt(); return enhancedPayload; @@ -330,12 +337,9 @@ function AcuantCapture( /** * Handler for file input change events. - * - * @param {File?} nextValue Next value, if set. */ - async function onUpload(nextValue) { - /** @type {ImageAnalyticsPayload=} */ - let analyticsPayload; + async function onUpload(nextValue: File | null) { + let analyticsPayload: ImageAnalyticsPayload | undefined; if (nextValue) { const { width, height } = await getImageDimensions(nextValue); @@ -356,17 +360,10 @@ function AcuantCapture( /** * Given a click source, returns a higher-order function that, when called, will log an event * before calling the original function. - * - * @template {(...args: any[]) => any} T - * - * @param {string} source Click source. - * @param {{isDrop: boolean}=} metadata Additional payload metadata to log. - * - * @return {(fn: T) => (...args: Parameters) => ReturnType} */ - function withLoggedClick(source, metadata = { isDrop: false }) { - return (fn) => - (...args) => { + function withLoggedClick(source: string, metadata: { isDrop: boolean } = { isDrop: false }) { + return (fn: (...args: any[]) => any) => + (...args: Parameters<(...args: any) => any>) => { if (!isSuppressingClickLogging.current) { addPageAction(`IdV: ${name} image clicked`, { source, ...metadata }); } @@ -378,9 +375,8 @@ function AcuantCapture( /** * Calls the given function, during which time any normal click logging will be suppressed. * - * @param {() => any} fn Function to call */ - function withoutClickLogging(fn) { + function withoutClickLogging(fn: () => any) { isSuppressingClickLogging.current = true; fn(); isSuppressingClickLogging.current = false; @@ -415,10 +411,8 @@ function AcuantCapture( * Responds to a click by starting capture if supported in the environment, or triggering the * default file picker prompt. The click event may originate from the file input itself, or * another element which aims to trigger the prompt of the file input. - * - * @param {import('react').MouseEvent} event Click event. */ - function startCaptureOrTriggerUpload(event) { + function startCaptureOrTriggerUpload(event: MouseEvent) { if (event.target === inputRef.current) { if (forceNativeCamera) { addPageAction('IdV: Native camera forced after failed attempts', { @@ -438,7 +432,7 @@ function AcuantCapture( } if (shouldStartSelfieCapture) { - /** @type {AcuantGlobal} */ (window).AcuantPassiveLiveness.startSelfieCapture( + window.AcuantPassiveLiveness.startSelfieCapture( ifStillMounted((nextImageData) => { const dataURI = `data:image/jpeg;base64,${nextImageData}`; onChangeAndResetError(dataURI); @@ -455,16 +449,37 @@ function AcuantCapture( } /** - * @param {AcuantSuccessResponse} nextCapture + * Triggers upload to occur, regardless of support for direct capture. This is necessary since the + * default behavior for interacting with the file input is intercepted when capture is supported. + * Calling `forceUpload` will flag the click handling to skip intercepting the event as capture. */ - function onAcuantImageCaptureSuccess(nextCapture) { + function forceUpload() { + if (!inputRef.current) { + return; + } + + isForceUploading.current = true; + + const originalCapture = inputRef.current.getAttribute('capture'); + + if (originalCapture !== null) { + inputRef.current.removeAttribute('capture'); + } + + withoutClickLogging(() => inputRef.current?.click()); + + if (originalCapture !== null) { + inputRef.current.setAttribute('capture', originalCapture); + } + } + + function onAcuantImageCaptureSuccess(nextCapture: AcuantSuccessResponse) { const { image, cardType, dpi, moire, glare, sharpness } = nextCapture; const isAssessedAsGlare = glare < glareThreshold; const isAssessedAsBlurry = sharpness < sharpnessThreshold; const { width, height, data } = image; - /** @type {AcuantImageAssessment} */ - let assessment; + let assessment: AcuantImageAssessment; if (isAssessedAsGlare) { setOwnErrorMessage(t('doc_auth.errors.glare.failed_short')); assessment = 'glare'; @@ -475,8 +490,7 @@ function AcuantCapture( assessment = 'success'; } - /** @type {AcuantImageAnalyticsPayload} */ - const analyticsPayload = getAddAttemptAnalyticsPayload({ + const analyticsPayload: AcuantImageAnalyticsPayload = getAddAttemptAnalyticsPayload({ width, height, mimeType: 'image/jpeg', // Acuant Web SDK currently encodes all images as JPEG @@ -513,8 +527,7 @@ function AcuantCapture( onCropStart={() => setHasStartedCropping(true)} onImageCaptureSuccess={onAcuantImageCaptureSuccess} onImageCaptureFailure={(error, code) => { - const { SEQUENCE_BREAK_CODE } = /** @type {AcuantGlobal} */ (window) - .AcuantJavascriptWebSdk; + const { SEQUENCE_BREAK_CODE } = window.AcuantJavascriptWebSdk; if (isAcuantCameraAccessFailure(error)) { if (fullScreenRef.current?.focusTrap) { suspendFocusTrapForAnticipatedFocus(fullScreenRef.current.focusTrap); diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx new file mode 100644 index 00000000000..ceae4d4dd4b --- /dev/null +++ b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx @@ -0,0 +1,97 @@ +import { createContext, useState } from 'react'; +import useCounter from '../hooks/use-counter'; + +import type { ReactNode } from 'react'; + +interface CaptureAttemptMetadata { + isAssessedAsGlare: boolean; + isAssessedAsBlurry: boolean; +} + +interface FailedCaptureAttemptsContextInterface { + /** + * Current number of failed capture attempts + */ + failedCaptureAttempts: number; + /** + * Number of failed attempts before showing tips + */ + maxFailedAttemptsBeforeTips: number; + /** + * The maximum number of failed Acuant capture attempts + * before use of the native camera option is triggered + */ + maxAttemptsBeforeNativeCamera: number; + /** + * Callback triggered on attempt, to increment attempts + */ + onFailedCaptureAttempt: (metadata: CaptureAttemptMetadata) => void; + /** + * Callback to trigger a reset of attempts + */ + onResetFailedCaptureAttempts: () => void; + /** + * Metadata about the last attempt + */ + lastAttemptMetadata: CaptureAttemptMetadata; +} + +const DEFAULT_LAST_ATTEMPT_METADATA: CaptureAttemptMetadata = { + isAssessedAsGlare: false, + isAssessedAsBlurry: false, +}; + +const FailedCaptureAttemptsContext = createContext({ + failedCaptureAttempts: 0, + onFailedCaptureAttempt: () => {}, + onResetFailedCaptureAttempts: () => {}, + maxAttemptsBeforeNativeCamera: Infinity, + maxFailedAttemptsBeforeTips: Infinity, + lastAttemptMetadata: DEFAULT_LAST_ATTEMPT_METADATA, +}); + +FailedCaptureAttemptsContext.displayName = 'FailedCaptureAttemptsContext'; + +interface FailedCaptureAttemptsContextProviderProps { + children: ReactNode; + maxFailedAttemptsBeforeTips: number; + maxAttemptsBeforeNativeCamera: number; +} + +/** + * @param {FailedCaptureAttemptsContextProviderProps} props + */ +function FailedCaptureAttemptsContextProvider({ + children, + maxFailedAttemptsBeforeTips, + maxAttemptsBeforeNativeCamera, +}: FailedCaptureAttemptsContextProviderProps) { + const [lastAttemptMetadata, setLastAttemptMetadata] = useState( + DEFAULT_LAST_ATTEMPT_METADATA, + ); + const [failedCaptureAttempts, incrementFailedCaptureAttempts, onResetFailedCaptureAttempts] = + useCounter(); + + function onFailedCaptureAttempt(metadata: CaptureAttemptMetadata) { + incrementFailedCaptureAttempts(); + setLastAttemptMetadata(metadata); + } + + return ( + + {children} + + ); +} + +export default FailedCaptureAttemptsContext; +export { FailedCaptureAttemptsContextProvider as Provider }; From 113a352a8e74c1fde8bfbaca71f1432268a61009 Mon Sep 17 00:00:00 2001 From: eric-gade Date: Wed, 17 Aug 2022 11:28:14 -0400 Subject: [PATCH 02/14] Post-rebase fixes changelog: Improvements, TypeScript, rebasing typescript changes on main and associated fixes --- .../components/acuant-capture.tsx | 25 ------------------- .../context/failed-capture-attempts.tsx | 12 ++++++--- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 418b457ee30..00d66b591c5 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -382,31 +382,6 @@ function AcuantCapture( isSuppressingClickLogging.current = false; } - /** - * Triggers upload to occur, regardless of support for direct capture. This is necessary since the - * default behavior for interacting with the file input is intercepted when capture is supported. - * Calling `forceUpload` will flag the click handling to skip intercepting the event as capture. - */ - function forceUpload() { - if (!inputRef.current) { - return; - } - - isForceUploading.current = true; - - const originalCapture = inputRef.current.getAttribute('capture'); - - if (originalCapture !== null) { - inputRef.current.removeAttribute('capture'); - } - - withoutClickLogging(() => inputRef.current?.click()); - - if (originalCapture !== null) { - inputRef.current.setAttribute('capture', originalCapture); - } - } - /** * Responds to a click by starting capture if supported in the environment, or triggering the * default file picker prompt. The click event may originate from the file input itself, or diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx index ceae4d4dd4b..23669c2c62d 100644 --- a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx +++ b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx @@ -34,6 +34,11 @@ interface FailedCaptureAttemptsContextInterface { * Metadata about the last attempt */ lastAttemptMetadata: CaptureAttemptMetadata; + /** + * Whether or not the native camera is currently being forced + * after maxAttemptsBeforeNativeCamera number of failed attempts + */ + forceNativeCamera: boolean; } const DEFAULT_LAST_ATTEMPT_METADATA: CaptureAttemptMetadata = { @@ -48,6 +53,7 @@ const FailedCaptureAttemptsContext = createContext= maxAttemptsBeforeNativeCamera; + return ( {children} From 65d89ca2be697e1df6ec7c65ebf9b673fa06b2bb Mon Sep 17 00:00:00 2001 From: eric-gade Date: Wed, 17 Aug 2022 12:22:35 -0400 Subject: [PATCH 03/14] Fixing linting issues changelog: Improvements, TypeScript, fixing linting issues --- .../components/acuant-capture.tsx | 70 ++++++++++--------- .../context/failed-capture-attempts.tsx | 3 +- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 00d66b591c5..0fbf0a113b5 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -11,6 +11,9 @@ import { import { useI18n } from '@18f/identity-react-i18n'; import { useIfStillMounted, useDidUpdateEffect } from '@18f/identity-react-hooks'; import { Button, FullScreen } from '@18f/identity-components'; +import type { FullScreenRefHandle } from '@18f/identity-components'; +import type { FocusTrap } from 'focus-trap'; +import type { ReactNode, MouseEvent } from 'react'; import AnalyticsContext from '../context/analytics'; import AcuantContext from '../context/acuant'; import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; @@ -21,12 +24,11 @@ import DeviceContext from '../context/device'; import UploadContext from '../context/upload'; import useCounter from '../hooks/use-counter'; import useCookie from '../hooks/use-cookie'; -import type { ReactNode, MouseEvent } from 'react'; -import type { AcuantSuccessResponse } from './acuant-camera'; -import type { AcuantDocumentType } from './acuant-camera'; -import type { AcuantCaptureFailureError } from './acuant-camera'; -import type { FullScreenRefHandle } from '@18f/identity-components'; -import type { FocusTrap } from 'focus-trap'; +import type { + AcuantSuccessResponse, + AcuantDocumentType, + AcuantCaptureFailureError, +} from './acuant-camera'; type AcuantDocumentTypeLabel = 'id' | 'passport' | 'none'; type AcuantImageAssessment = 'success' | 'glare' | 'blurry'; @@ -357,13 +359,15 @@ function AcuantCapture( onChangeAndResetError(nextValue, analyticsPayload); } + type LoggedClickCallback = (...args: any[]) => any; + /** * Given a click source, returns a higher-order function that, when called, will log an event * before calling the original function. */ function withLoggedClick(source: string, metadata: { isDrop: boolean } = { isDrop: false }) { - return (fn: (...args: any[]) => any) => - (...args: Parameters<(...args: any) => any>) => { + return (fn: LoggedClickCallback) => + (...args: Parameters) => { if (!isSuppressingClickLogging.current) { addPageAction(`IdV: ${name} image clicked`, { source, ...metadata }); } @@ -382,6 +386,31 @@ function AcuantCapture( isSuppressingClickLogging.current = false; } + /** + * Triggers upload to occur, regardless of support for direct capture. This is necessary since the + * default behavior for interacting with the file input is intercepted when capture is supported. + * Calling `forceUpload` will flag the click handling to skip intercepting the event as capture. + */ + function forceUpload() { + if (!inputRef.current) { + return; + } + + isForceUploading.current = true; + + const originalCapture = inputRef.current.getAttribute('capture'); + + if (originalCapture !== null) { + inputRef.current.removeAttribute('capture'); + } + + withoutClickLogging(() => inputRef.current?.click()); + + if (originalCapture !== null) { + inputRef.current.setAttribute('capture', originalCapture); + } + } + /** * Responds to a click by starting capture if supported in the environment, or triggering the * default file picker prompt. The click event may originate from the file input itself, or @@ -423,31 +452,6 @@ function AcuantCapture( } } - /** - * Triggers upload to occur, regardless of support for direct capture. This is necessary since the - * default behavior for interacting with the file input is intercepted when capture is supported. - * Calling `forceUpload` will flag the click handling to skip intercepting the event as capture. - */ - function forceUpload() { - if (!inputRef.current) { - return; - } - - isForceUploading.current = true; - - const originalCapture = inputRef.current.getAttribute('capture'); - - if (originalCapture !== null) { - inputRef.current.removeAttribute('capture'); - } - - withoutClickLogging(() => inputRef.current?.click()); - - if (originalCapture !== null) { - inputRef.current.setAttribute('capture', originalCapture); - } - } - function onAcuantImageCaptureSuccess(nextCapture: AcuantSuccessResponse) { const { image, cardType, dpi, moire, glare, sharpness } = nextCapture; const isAssessedAsGlare = glare < glareThreshold; diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx index 23669c2c62d..bc998e28c05 100644 --- a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx +++ b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx @@ -1,7 +1,6 @@ import { createContext, useState } from 'react'; -import useCounter from '../hooks/use-counter'; - import type { ReactNode } from 'react'; +import useCounter from '../hooks/use-counter'; interface CaptureAttemptMetadata { isAssessedAsGlare: boolean; From 74b7750d59beb5f07dd03e15e4068abadf03af47 Mon Sep 17 00:00:00 2001 From: Eric Gade <105373963+eric-gade@users.noreply.github.com> Date: Wed, 17 Aug 2022 13:47:31 -0400 Subject: [PATCH 04/14] Update app/javascript/packages/document-capture/components/acuant-capture.tsx Co-authored-by: Andrew Duthie --- .../packages/document-capture/components/acuant-capture.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 0fbf0a113b5..7edd9177d64 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -6,14 +6,13 @@ import { useMemo, useEffect, useImperativeHandle, - Ref, } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; import { useIfStillMounted, useDidUpdateEffect } from '@18f/identity-react-hooks'; import { Button, FullScreen } from '@18f/identity-components'; import type { FullScreenRefHandle } from '@18f/identity-components'; import type { FocusTrap } from 'focus-trap'; -import type { ReactNode, MouseEvent } from 'react'; +import type { ReactNode, MouseEvent, Ref } from 'react'; import AnalyticsContext from '../context/analytics'; import AcuantContext from '../context/acuant'; import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; From 987422a79e27aeee8fe040070a6046b19ac780f2 Mon Sep 17 00:00:00 2001 From: Eric Gade <105373963+eric-gade@users.noreply.github.com> Date: Wed, 17 Aug 2022 13:48:01 -0400 Subject: [PATCH 05/14] Update app/javascript/packages/document-capture/components/acuant-capture.tsx Co-authored-by: Zach Margolis --- .../packages/document-capture/components/acuant-capture.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 7edd9177d64..95309f3198a 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -163,7 +163,7 @@ export function getNormalizedAcuantCaptureFailureMessage( } const { REPEAT_FAIL_CODE, SEQUENCE_BREAK_CODE } = - /** @type {AcuantGlobal} */ window.AcuantJavascriptWebSdk; + (window as AcuantGlobal).AcuantJavascriptWebSdk; switch (code) { case REPEAT_FAIL_CODE: From 37bff196d64325c4a610ad0db53e13fa1a90dd9b Mon Sep 17 00:00:00 2001 From: Eric Gade <105373963+eric-gade@users.noreply.github.com> Date: Wed, 17 Aug 2022 13:48:24 -0400 Subject: [PATCH 06/14] Update app/javascript/packages/document-capture/components/acuant-capture.tsx Co-authored-by: Zach Margolis --- .../packages/document-capture/components/acuant-capture.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 95309f3198a..89af9c585a2 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -125,7 +125,7 @@ interface AcuantCaptureProps { * Non-breaking space (` `) represented as unicode escape sequence, which React will more * happily tolerate than an HTML entity. */ -const NBSP_UNICODE: string = '\u00A0'; +const NBSP_UNICODE = '\u00A0'; /** * A noop function. From ea105a64e655a22225d95f4e72a40ef8ca4358e7 Mon Sep 17 00:00:00 2001 From: Eric Gade <105373963+eric-gade@users.noreply.github.com> Date: Wed, 17 Aug 2022 13:50:02 -0400 Subject: [PATCH 07/14] Update app/javascript/packages/document-capture/components/acuant-capture.tsx Co-authored-by: Zach Margolis --- .../packages/document-capture/components/acuant-capture.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 89af9c585a2..364621b5004 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -136,7 +136,7 @@ const noop = () => {}; * Returns true if the given Acuant capture failure was caused by the user declining access to the * camera, or false otherwise. */ -export const isAcuantCameraAccessFailure = (error: AcuantCaptureFailureError) => +export const isAcuantCameraAccessFailure = (error: AcuantCaptureFailureError): error is Error => error instanceof Error; /** From 8d31f0a628d7cabc3c73f6c1f804d1ff4d5d7ceb Mon Sep 17 00:00:00 2001 From: eric-gade Date: Wed, 17 Aug 2022 13:55:02 -0400 Subject: [PATCH 08/14] Fixing incorrect optional props in ImageAnalyticsPayload Also switching to the global window object, which already is annotated with the AcuantJavaScriptWebSdk interface changelog: Improvements, TypeScript, updating interface property types --- .../document-capture/components/acuant-capture.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 364621b5004..0e188c56111 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -37,15 +37,15 @@ interface ImageAnalyticsPayload { /** * Image width, or null if unknown */ - width?: number | null; + width: number | null; /** * Image height, or null if unknown */ - height?: number | null; + height: number | null; /** * Mime type, or null if unknown */ - mimeType?: string | null; + mimeType: string | null; /** * Method by which the image was added */ @@ -136,7 +136,7 @@ const noop = () => {}; * Returns true if the given Acuant capture failure was caused by the user declining access to the * camera, or false otherwise. */ -export const isAcuantCameraAccessFailure = (error: AcuantCaptureFailureError): error is Error => +export const isAcuantCameraAccessFailure = (error: AcuantCaptureFailureError): error is Error => error instanceof Error; /** @@ -162,8 +162,7 @@ export function getNormalizedAcuantCaptureFailureMessage( return 'User or system denied camera access'; } - const { REPEAT_FAIL_CODE, SEQUENCE_BREAK_CODE } = - (window as AcuantGlobal).AcuantJavascriptWebSdk; + const { REPEAT_FAIL_CODE, SEQUENCE_BREAK_CODE } = window.AcuantJavascriptWebSdk; switch (code) { case REPEAT_FAIL_CODE: From 84bd8d5761fa5d3e02de745a3788db80ce510b32 Mon Sep 17 00:00:00 2001 From: Eric Gade <105373963+eric-gade@users.noreply.github.com> Date: Thu, 18 Aug 2022 15:04:02 -0400 Subject: [PATCH 09/14] Update app/javascript/packages/document-capture/components/acuant-capture.tsx Co-authored-by: Andrew Duthie --- .../packages/document-capture/components/acuant-capture.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 0e188c56111..09aa8ea861b 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -364,8 +364,8 @@ function AcuantCapture( * before calling the original function. */ function withLoggedClick(source: string, metadata: { isDrop: boolean } = { isDrop: false }) { - return (fn: LoggedClickCallback) => - (...args: Parameters) => { + return any>(fn: T) => + (...args: Parameters) => { if (!isSuppressingClickLogging.current) { addPageAction(`IdV: ${name} image clicked`, { source, ...metadata }); } From 75b131caf869a21177a9942f40cae8fde05d71f1 Mon Sep 17 00:00:00 2001 From: Eric Gade <105373963+eric-gade@users.noreply.github.com> Date: Thu, 18 Aug 2022 15:04:07 -0400 Subject: [PATCH 10/14] Update app/javascript/packages/document-capture/components/acuant-capture.tsx Co-authored-by: Andrew Duthie --- .../packages/document-capture/components/acuant-capture.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 09aa8ea861b..e4ba259c3c7 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -329,7 +329,9 @@ function AcuantCapture( /** * Returns an analytics payload, decorated with common values. */ - function getAddAttemptAnalyticsPayload

(payload: P): P { + function getAddAttemptAnalyticsPayload< + P extends ImageAnalyticsPayload | AcuantImageAnalyticsPayload, + >(payload: P): P { const enhancedPayload = { ...payload, attempt }; incrementAttempt(); return enhancedPayload; From d79131b283cef7eeb8efe7393584308902b77bcc Mon Sep 17 00:00:00 2001 From: Eric Gade <105373963+eric-gade@users.noreply.github.com> Date: Thu, 18 Aug 2022 15:04:18 -0400 Subject: [PATCH 11/14] Update app/javascript/packages/document-capture/components/acuant-capture.tsx Co-authored-by: Andrew Duthie --- .../packages/document-capture/components/acuant-capture.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index e4ba259c3c7..9e7b8e71ca0 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -240,7 +240,7 @@ function suspendFocusTrapForAnticipatedFocus(focusTrap: FocusTrap) { }, 0); } -export function getDecodedBase64ByteSize(data: string | any[]) { +export function getDecodedBase64ByteSize(data: string) { let bytes = 0.75 * data.length; let i = data.length; From e05e7b2aadd16a31071f2b28afd113934d83062c Mon Sep 17 00:00:00 2001 From: Eric Gade <105373963+eric-gade@users.noreply.github.com> Date: Thu, 18 Aug 2022 15:04:28 -0400 Subject: [PATCH 12/14] Update app/javascript/packages/document-capture/components/acuant-capture.tsx Co-authored-by: Andrew Duthie --- .../packages/document-capture/components/acuant-capture.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 9e7b8e71ca0..b14305949fa 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -320,7 +320,7 @@ function AcuantCapture( */ function onChangeAndResetError( nextValue: Blob | string | null, - metadata?: ImageAnalyticsPayload | undefined, + metadata?: ImageAnalyticsPayload, ) { setOwnErrorMessage(null); onChange(nextValue, metadata); From 6d0a37729421187bb13137bddb30d63ff6a8bd9d Mon Sep 17 00:00:00 2001 From: eric-gade Date: Thu, 18 Aug 2022 15:13:04 -0400 Subject: [PATCH 13/14] Using interface extension for TS types Additionally, I have renamed failed-capture-context.jsx to tsx explicitly changelog: Improvements, TypeScript, updating type definitions --- .../components/acuant-capture.tsx | 4 +- .../context/failed-capture-attempts.jsx | 96 ------------------- 2 files changed, 1 insertion(+), 99 deletions(-) delete mode 100644 app/javascript/packages/document-capture/context/failed-capture-attempts.jsx diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index b14305949fa..301aca0031b 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -60,7 +60,7 @@ interface ImageAnalyticsPayload { size: number; } -interface _AcuantImageAnalyticsPayload { +interface AcuantImageAnalyticsPayload extends ImageAnalyticsPayload { documentType: AcuantDocumentTypeLabel; dpi: number; moire: number; @@ -73,8 +73,6 @@ interface _AcuantImageAnalyticsPayload { assessment: AcuantImageAssessment; } -type AcuantImageAnalyticsPayload = ImageAnalyticsPayload & _AcuantImageAnalyticsPayload; - interface AcuantCaptureProps { /** * Label associated with file input diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx deleted file mode 100644 index 0ce5887c00e..00000000000 --- a/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import { createContext, useState } from 'react'; -import useCounter from '../hooks/use-counter'; - -/** @typedef {import('react').ReactNode} ReactNode */ - -/** - * @typedef CaptureAttemptMetadata - * - * @prop {boolean} isAssessedAsGlare - * @prop {boolean} isAssessedAsBlurry - */ - -/** - * @typedef FailedCaptureAttemptsContext - * - * @prop {number} failedCaptureAttempts Current number of failed attempts. - * @prop {(metadata: CaptureAttemptMetadata)=>void} onFailedCaptureAttempt Callback to trigger on - * attempt, to increment attempts. - * @prop {() => void} onResetFailedCaptureAttempts Callback to trigger a reset of attempts. - * @prop {number} maxFailedAttemptsBeforeTips Number of failed attempts before showing tips. - * @prop {number} maxAttemptsBeforeNativeCamera Number of attempts before forcing the use of the native camera (if available) - * @prop {CaptureAttemptMetadata} lastAttemptMetadata Metadata about the last attempt. - * @prop {boolean} forceNativeCamera Whether or not to force use of the native camera. Is set to true if the number of failedCaptureAttempts is equal to or greater than maxAttemptsBeforeNativeCamera - */ - -/** @type {CaptureAttemptMetadata} */ -const DEFAULT_LAST_ATTEMPT_METADATA = { - isAssessedAsGlare: false, - isAssessedAsBlurry: false, -}; - -const FailedCaptureAttemptsContext = createContext( - /** @type {FailedCaptureAttemptsContext} */ ({ - failedCaptureAttempts: 0, - onFailedCaptureAttempt: () => {}, - onResetFailedCaptureAttempts: () => {}, - maxAttemptsBeforeNativeCamera: Infinity, - maxFailedAttemptsBeforeTips: Infinity, - lastAttemptMetadata: DEFAULT_LAST_ATTEMPT_METADATA, - forceNativeCamera: false, - }), -); - -FailedCaptureAttemptsContext.displayName = 'FailedCaptureAttemptsContext'; - -/** - * @typedef FailedCaptureAttemptsContextProviderProps - * - * @prop {ReactNode} children - * @prop {number} maxFailedAttemptsBeforeTips - * @prop {number} maxAttemptsBeforeNativeCamera - */ - -/** - * @param {FailedCaptureAttemptsContextProviderProps} props - */ -function FailedCaptureAttemptsContextProvider({ - children, - maxFailedAttemptsBeforeTips, - maxAttemptsBeforeNativeCamera, -}) { - const [lastAttemptMetadata, setLastAttemptMetadata] = useState( - /** @type {CaptureAttemptMetadata} */ (DEFAULT_LAST_ATTEMPT_METADATA), - ); - const [failedCaptureAttempts, incrementFailedCaptureAttempts, onResetFailedCaptureAttempts] = - useCounter(); - - const forceNativeCamera = failedCaptureAttempts >= maxAttemptsBeforeNativeCamera; - - /** - * @param {CaptureAttemptMetadata} metadata - */ - function onFailedCaptureAttempt(metadata) { - incrementFailedCaptureAttempts(); - setLastAttemptMetadata(metadata); - } - - return ( - - {children} - - ); -} - -export default FailedCaptureAttemptsContext; -export { FailedCaptureAttemptsContextProvider as Provider }; From 60fbdec6debeb5a0e708a3b322f58f4004cc7ffd Mon Sep 17 00:00:00 2001 From: eric-gade Date: Thu, 18 Aug 2022 16:16:53 -0400 Subject: [PATCH 14/14] Removing dead type definition changelog: Improvements, TypeScript, removing old type definition --- .../packages/document-capture/components/acuant-capture.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 301aca0031b..36598517bb5 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -357,8 +357,6 @@ function AcuantCapture( onChangeAndResetError(nextValue, analyticsPayload); } - type LoggedClickCallback = (...args: any[]) => any; - /** * Given a click source, returns a higher-order function that, when called, will log an event * before calling the original function.