-
Notifications
You must be signed in to change notification settings - Fork 166
LG-11377 Add Selfie UI #9580
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
LG-11377 Add Selfie UI #9580
Changes from all commits
21a7d2e
3db39bc
ebdd725
96186fc
5a111db
6ac238c
27e44eb
8c47bcc
8e943e4
472144a
92d3bf8
a36dcd2
9ad8da9
a7ac604
6734fd5
563e85f
bfa1b91
a86e9ff
ee25292
ae8fdb7
3d3741e
f820b91
884b02a
8257519
6a22934
bd3cccd
64c9de3
2edddaf
b62a92c
81c8811
7c2f578
8989b94
e020879
efb9af4
cb289bb
f14748e
541d507
0a66cee
62c65e5
2f005cf
503940a
d471255
9eeab88
f3dffb9
170080a
94cf66b
23d087d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,8 @@ import { useDidUpdateEffect } from '@18f/identity-react-hooks'; | |
| import { useI18n } from '@18f/identity-react-i18n'; | ||
| import { removeUnloadProtection } from '@18f/identity-url'; | ||
| import AcuantCamera, { AcuantDocumentType } from './acuant-camera'; | ||
| import AcuantSelfieCamera from './acuant-selfie-camera'; | ||
| import AcuantSelfieCaptureCanvas from './acuant-selfie-capture-canvas'; | ||
| import type { | ||
| AcuantCaptureFailureError, | ||
| AcuantSuccessResponse, | ||
|
|
@@ -335,6 +337,9 @@ function AcuantCapture( | |
| const [attempt, incrementAttempt] = useCounter(1); | ||
| const [acuantFailureCookie, setAcuantFailureCookie, refreshAcuantFailureCookie] = | ||
| useCookie('AcuantCameraHasFailed'); | ||
| // There's some pretty significant changes to this component when it's used for | ||
charleyf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // selfie capture vs document image capture. This controls those changes. | ||
| const selfieCapture = name === 'selfie'; | ||
|
|
||
| const { | ||
| failedCaptureAttempts, | ||
|
|
@@ -488,6 +493,30 @@ function AcuantCapture( | |
| } | ||
| } | ||
|
|
||
| function onSelfieCaptureSuccess({ image }: { image: string }) { | ||
| onChangeAndResetError(image); | ||
| onResetFailedCaptureAttempts(); | ||
| setIsCapturingEnvironment(false); | ||
| } | ||
|
|
||
| function onSelfieCaptureFailure() { | ||
| // Internally, Acuant sets a cookie to bail on guided capture if initialization had | ||
| // previously failed for any reason, including declined permission. Since the cookie | ||
| // never expires, and since we want to re-prompt even if the user had previously | ||
| // declined, unset the cookie value when failure occurs for permissions. | ||
| setAcuantFailureCookie(null); | ||
| onCameraAccessDeclined(); | ||
|
|
||
| // Due to a bug with Safari on iOS we force the page to refresh on the third | ||
| // time a user denies permissions. | ||
| onFailedCameraPermissionAttempt(); | ||
| if (failedCameraPermissionAttempts > 2) { | ||
| removeUnloadProtection(); | ||
| window.location.reload(); | ||
| } | ||
| setIsCapturingEnvironment(false); | ||
| } | ||
|
|
||
| function onAcuantImageCaptureSuccess( | ||
| nextCapture: AcuantSuccessResponse | LegacyAcuantSuccessResponse, | ||
| ) { | ||
|
|
@@ -598,7 +627,7 @@ function AcuantCapture( | |
|
|
||
| return ( | ||
| <div className={[className, 'document-capture-acuant-capture'].filter(Boolean).join(' ')}> | ||
| {isCapturingEnvironment && ( | ||
| {isCapturingEnvironment && !selfieCapture && ( | ||
| <AcuantCamera | ||
| onCropStart={() => setHasStartedCropping(true)} | ||
| onImageCaptureSuccess={onAcuantImageCaptureSuccess} | ||
|
|
@@ -615,6 +644,20 @@ function AcuantCapture( | |
| )} | ||
| </AcuantCamera> | ||
| )} | ||
| {isCapturingEnvironment && selfieCapture && ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a little confusing for me to read, since "environment" refers to the rear-facing camera of a phone, which is the complete opposite of what we mean when we say selfie capture. It was originally introduced to differentiate between selfie and non-selfie, where selfie is capturing the user, not the environment. https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, interesting.
I'm going to think about this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting, then we need add some comment to indicate that.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I might be affected by Monday brain fuzziness, but I thought this, too. I wonder if part of why we thought this was because in our previous work with this code, whenever the full screen overlay was open and providing the correct environment for capturing an image from the Acuant SDK, the user would also be using the back cam on phones? The conditions overlapped for id image capture in a way that they don't overlap for selfie capture. (I agree with Dawei that it would be good to have comments/documentation about this.)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After considering the code again, I do think that that the Since that's confusing, I'm writing a ticket to change:
I'm not thrilled that I'm deferring the two major revisions requested on this PR, but I'm not seeing a great way to address them here either and this code is not intended for users in it's current "proof of concept" state.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's the followup tickets to address the tasks I'm deferring from this PR (Slack of same list):
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am hoping/planning to merge this ticket as is later today. |
||
| <AcuantSelfieCamera | ||
| onImageCaptureSuccess={onSelfieCaptureSuccess} | ||
| onImageCaptureFailure={onSelfieCaptureFailure} | ||
| onImageCaptureOpen={() => setIsCapturingEnvironment(true)} | ||
| onImageCaptureClose={() => setIsCapturingEnvironment(false)} | ||
| > | ||
| <AcuantSelfieCaptureCanvas | ||
| fullScreenRef={fullScreenRef} | ||
| fullScreenLabel={t('doc_auth.accessible_labels.document_capture_dialog')} | ||
| onRequestClose={() => setIsCapturingEnvironment(false)} | ||
| /> | ||
| </AcuantSelfieCamera> | ||
| )} | ||
| <FileInput | ||
| ref={inputRef} | ||
| label={label} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import { useContext, useEffect } from 'react'; | ||
| import type { ReactNode } from 'react'; | ||
| import AcuantContext from '../context/acuant'; | ||
|
|
||
| declare global { | ||
| interface Window { | ||
| AcuantPassiveLiveness: AcuantPassiveLivenessInterface; | ||
| } | ||
| } | ||
|
|
||
| type AcuantPassiveLivenessStart = ( | ||
| faceCaptureCallback: FaceCaptureCallback, | ||
| faceDetectionStates: FaceDetectionStates, | ||
| ) => void; | ||
|
|
||
| interface AcuantPassiveLivenessInterface { | ||
| /** | ||
| * Start capture | ||
| */ | ||
| start: AcuantPassiveLivenessStart; | ||
| /** | ||
| * End capture | ||
| */ | ||
| end: () => void; | ||
| } | ||
|
|
||
| interface AcuantSelfieCameraContextProps { | ||
| /** | ||
| * Success callback | ||
| */ | ||
| onImageCaptureSuccess: ({ image }: { image: string }) => void; | ||
| /** | ||
| * Failure callback | ||
| */ | ||
| onImageCaptureFailure: any; | ||
| /** | ||
| * Capture open callback, tells the rest of the page | ||
| * when the fullscreen selfie capture page is open | ||
| */ | ||
| onImageCaptureOpen: () => void; | ||
| /** | ||
| * Capture close callback, tells the rest of the page | ||
| * when the fullscreen selfie capture page has been closed | ||
| */ | ||
| onImageCaptureClose: () => void; | ||
| /** | ||
| * React children node | ||
| */ | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| interface FaceCaptureCallback { | ||
| onDetectorInitialized: () => void; | ||
| onDetection: (text) => void; | ||
| onOpened: () => void; | ||
| onClosed: () => void; | ||
| onError: (error) => void; | ||
| onPhotoTaken: () => void; | ||
| onPhotoRetake: () => void; | ||
| onCaptured: (base64Image: Blob) => void; | ||
| } | ||
|
|
||
| interface FaceDetectionStates { | ||
| FACE_NOT_FOUND: string; | ||
| TOO_MANY_FACES: string; | ||
| FACE_ANGLE_TOO_LARGE: string; | ||
| PROBABILITY_TOO_SMALL: string; | ||
| FACE_TOO_SMALL: string; | ||
| FACE_CLOSE_TO_BORDER: string; | ||
| } | ||
|
|
||
| function AcuantSelfieCamera({ | ||
| onImageCaptureSuccess = () => {}, | ||
| onImageCaptureFailure = () => {}, | ||
| onImageCaptureOpen = () => {}, | ||
| onImageCaptureClose = () => {}, | ||
| children, | ||
| }: AcuantSelfieCameraContextProps) { | ||
| const { isReady, setIsActive } = useContext(AcuantContext); | ||
|
|
||
| useEffect(() => { | ||
| const faceCaptureCallback: FaceCaptureCallback = { | ||
| onDetectorInitialized: () => { | ||
| // This callback is triggered when the face detector is ready. | ||
| // Until then, no actions are executed and the user sees only the camera stream. | ||
| // You can opt to display an alert before the callback is triggered. | ||
| }, | ||
| onDetection: () => { | ||
| // Triggered when the face does not pass the scan. The UI element | ||
| // should be updated here to provide guidence to the user | ||
| }, | ||
| onOpened: () => { | ||
| // Camera has opened | ||
| onImageCaptureOpen(); | ||
| }, | ||
| onClosed: () => { | ||
| // Camera has closed | ||
| onImageCaptureClose(); | ||
| }, | ||
| onError: (error) => { | ||
| // Error occurred. Camera permission not granted will | ||
| // manifest here with 1 as error code. Unexpected errors will have 2 as error code. | ||
| onImageCaptureFailure({ error }); | ||
| }, | ||
| onPhotoTaken: () => { | ||
| // The photo has been taken and it's showing a preview with a button to accept or retake the image. | ||
| }, | ||
| onPhotoRetake: () => { | ||
| // Triggered when retake button is tapped | ||
| }, | ||
| onCaptured: (base64Image) => { | ||
| // Triggered when accept button is tapped | ||
| onImageCaptureSuccess({ image: `data:image/jpeg;base64,${base64Image}` }); | ||
| }, | ||
| }; | ||
|
|
||
| const faceDetectionStates = { | ||
| FACE_NOT_FOUND: 'FACE NOT FOUND', | ||
| TOO_MANY_FACES: 'TOO MANY FACES', | ||
| FACE_ANGLE_TOO_LARGE: 'FACE ANGLE TOO LARGE', | ||
| PROBABILITY_TOO_SMALL: 'PROBABILITY TOO SMALL', | ||
| FACE_TOO_SMALL: 'FACE TOO SMALL', | ||
| FACE_CLOSE_TO_BORDER: 'TOO CLOSE TO THE FRAME', | ||
| }; | ||
| const cleanupSelfieCamera = () => { | ||
| window.AcuantPassiveLiveness.end(); | ||
| setIsActive(false); | ||
| }; | ||
|
|
||
| const startSelfieCamera = () => { | ||
| window.AcuantPassiveLiveness.start(faceCaptureCallback, faceDetectionStates); | ||
| setIsActive(true); | ||
| }; | ||
|
|
||
| if (isReady) { | ||
| startSelfieCamera(); | ||
| } | ||
| // Cleanup when the AcuantSelfieCamera component is unmounted | ||
| return () => (isReady ? cleanupSelfieCamera() : undefined); | ||
| }, [isReady]); | ||
|
|
||
| return <>{children}</>; | ||
charleyf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| export default AcuantSelfieCamera; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { useContext } from 'react'; | ||
| import { getAssetPath } from '@18f/identity-assets'; | ||
| import { FullScreen } from '@18f/identity-components'; | ||
| import AcuantContext from '../context/acuant'; | ||
|
|
||
| function FullScreenLoadingSpinner({ fullScreenRef, onRequestClose, fullScreenLabel }) { | ||
| return ( | ||
| <FullScreen ref={fullScreenRef} label={fullScreenLabel} onRequestClose={onRequestClose}> | ||
| <img | ||
| src={getAssetPath('loading-badge.gif')} | ||
| alt="" | ||
| width="144" | ||
| height="144" | ||
| className="acuant-capture-canvas__spinner" | ||
| /> | ||
| </FullScreen> | ||
| ); | ||
| } | ||
|
|
||
| function AcuantSelfieCaptureCanvas({ fullScreenRef, onRequestClose, fullScreenLabel }) { | ||
| const { isReady } = useContext(AcuantContext); | ||
| // The Acuant SDK script AcuantPassiveLiveness attaches to whatever element has | ||
charleyf marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // this id. It then uses that element as the root for the full screen selfie capture | ||
| const acuantCaptureContainerId = 'acuant-face-capture-container'; | ||
| return isReady ? ( | ||
| <div id={acuantCaptureContainerId} /> | ||
| ) : ( | ||
| <FullScreenLoadingSpinner | ||
| fullScreenRef={fullScreenRef} | ||
| onRequestClose={onRequestClose} | ||
| fullScreenLabel={fullScreenLabel} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export default AcuantSelfieCaptureCanvas; | ||
Uh oh!
There was an error while loading. Please reload this page.