Skip to content

Commit

Permalink
fix: camera should correctly pick up qr codes on chrome (#193)
Browse files Browse the repository at this point in the history
* fix: use canvas to capture video feed when capturing via camera

* refactor: move scan qr code modal contents to seprate components

* feat: only show scan via camera option when camera is available

* chore: squash
  • Loading branch information
kieranroneill authored Mar 1, 2024
1 parent 09b468e commit 252b07b
Show file tree
Hide file tree
Showing 32 changed files with 380 additions and 182 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,7 @@ import {
Text,
VStack,
} from '@chakra-ui/react';
import React, {
FC,
MutableRefObject,
useEffect,
useRef,
useState,
} from 'react';
import React, { FC, MutableRefObject, useEffect, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import {
IoAlertCircleOutline,
Expand All @@ -35,110 +29,84 @@ import {
SUPPORT_MAIL_TO_LINK,
} from '@extension/constants';

// errors
import { BaseExtensionError, CameraError } from '@extension/errors';
// enums
import { ErrorCodeEnum, ScanModeEnum } from '@extension/enums';

// hooks
import useCameraStream from '@extension/hooks/useCameraStream';
import useCaptureQRCode from '@extension/hooks/useCaptureQRCode';
import useColorModeValue from '@extension/hooks/useColorModeValue';
import useDefaultTextColor from '@extension/hooks/useDefaultTextColor';
import useSubTextColor from '@extension/hooks/useSubTextColor';

// selectors
import { useSelectLogger } from '@extension/selectors';

// theme
import { theme } from '@extension/theme';

// types
import type { ILogger } from '@common/types';

interface IProps {
onPreviousClick: () => void;
}
import type { IScanQRCodeModalContentProps } from '@extension/types';

const ScanQRCodeModalCameraStreamContent: FC<IProps> = ({
const ScanQRCodeViaCameraModalContent: FC<IScanQRCodeModalContentProps> = ({
onPreviousClick,
}: IProps) => {
const { t } = useTranslation();
onURI,
}) => {
const videoRef: MutableRefObject<HTMLVideoElement | null> =
useRef<HTMLVideoElement | null>(null);
// selectors
const logger: ILogger = useSelectLogger();
const { t } = useTranslation();
// hooks
const {
error,
loading,
reset: resetCameraStream,
startStream,
stream,
} = useCameraStream();
const {
resetAction: resetScanAction,
startScanningAction,
uri,
} = useCaptureQRCode();
const defaultTextColor: string = useDefaultTextColor();
const primaryColor: string = useColorModeValue(
theme.colors.primaryLight['500'],
theme.colors.primaryDark['500']
);
const subTextColor: string = useSubTextColor();
// state
const [error, setError] = useState<BaseExtensionError | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [notAllowed, setNotAllowed] = useState<boolean>(false);
const [stream, setStream] = useState<MediaStream | null>(null);
// misc
const startStreaming = async () => {
const _functionName: string = 'useEffect';
let _stream: MediaStream;

if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
try {
setError(null);
setLoading(true);

_stream = await navigator.mediaDevices.getUserMedia({
video: {
height: window.innerHeight,
width: window.innerWidth,
},
});

setStream(_stream);
} catch (error) {
logger.error(
`${ScanQRCodeModalCameraStreamContent.name}#${_functionName}: `,
error
);

// if the user denied access, inform the user
if (
(error as DOMException).name === 'NotAllowedError' ||
(error as DOMException).name === 'SecurityError'
) {
setNotAllowed(true);
setLoading(false);

return;
}

setError(new CameraError((error as DOMException).name, error.message));
}

setLoading(false);
}
const reset = () => {
// stop scanning and stop streaming
resetCameraStream();
resetScanAction();
};
// handlers
const handlePreviousClick = () => {
// stop the camera stream
if (stream) {
stream.getTracks().forEach((value) => value.stop());
}

setError(null);
setLoading(false);
setStream(null);
setNotAllowed(false);

onPreviousClick();
reset();
};
// renders
const renderBody = () => {
if (stream) {
return <QRCodeFrameIcon color="white" h="20rem" w="20rem" />;
}

// show a general error page
if (error) {
// if the camera was denied access, show a special error
if (error.code === ErrorCodeEnum.CameraNotAllowedError) {
return (
<>
{/*icon*/}
<Icon as={IoBanOutline} color={defaultTextColor} h={16} w={16} />

{/*captions*/}
<Text color={defaultTextColor} fontSize="md" textAlign="center">
{t<string>('captions.cameraQRCodeScanNotAllowed1')}
</Text>
<Text color={defaultTextColor} fontSize="md" textAlign="center">
{t<string>('captions.cameraQRCodeScanNotAllowed2')}
</Text>
</>
);
}

return (
<>
{/*icon*/}
Expand Down Expand Up @@ -171,23 +139,6 @@ const ScanQRCodeModalCameraStreamContent: FC<IProps> = ({
);
}

if (notAllowed) {
return (
<>
{/*icon*/}
<Icon as={IoBanOutline} color={defaultTextColor} h={16} w={16} />

{/*captions*/}
<Text color={defaultTextColor} fontSize="md" textAlign="center">
{t<string>('captions.cameraQRCodeScanNotAllowed1')}
</Text>
<Text color={defaultTextColor} fontSize="md" textAlign="center">
{t<string>('captions.cameraQRCodeScanNotAllowed2')}
</Text>
</>
);
}

return (
<>
{/*loader*/}
Expand All @@ -208,14 +159,27 @@ const ScanQRCodeModalCameraStreamContent: FC<IProps> = ({
};

useEffect(() => {
(async () => await startStreaming())();
(async () => await startStream())();
}, []);
useEffect(() => {
if (stream && videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play();

// once we start the stream, start scanning for a qr code
startScanningAction({
mode: ScanModeEnum.Camera,
videoElement: videoRef.current,
});
}
}, [stream]);
useEffect(() => {
if (uri) {
onURI(uri);

reset();
}
}, [uri]);

return (
<ModalContent
Expand All @@ -240,7 +204,7 @@ const ScanQRCodeModalCameraStreamContent: FC<IProps> = ({
/>

{/*header*/}
{notAllowed && (
{error && error.code === ErrorCodeEnum.CameraNotAllowedError && (
<ModalHeader display="flex" justifyContent="center" px={DEFAULT_GAP}>
<Heading color={defaultTextColor} size="md" textAlign="center">
{t<string>('headings.cameraDenied')}
Expand Down Expand Up @@ -285,4 +249,4 @@ const ScanQRCodeModalCameraStreamContent: FC<IProps> = ({
);
};

export default ScanQRCodeModalCameraStreamContent;
export default ScanQRCodeViaCameraModalContent;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ScanQRCodeViaCameraModalContent';
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
Text,
VStack,
} from '@chakra-ui/react';
import React, { FC } from 'react';
import React, { FC, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { IoArrowBackOutline } from 'react-icons/io5';

Expand All @@ -18,29 +18,55 @@ import Button from '@extension/components/Button';
// constants
import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants';

// enums
import { ScanModeEnum } from '@extension/enums';

// hooks
import useDefaultTextColor from '@extension/hooks/useDefaultTextColor';
import useCaptureQRCode from '@extension/hooks/useCaptureQRCode';
import useColorModeValue from '@extension/hooks/useColorModeValue';
import useDefaultTextColor from '@extension/hooks/useDefaultTextColor';

// theme
import { theme } from '@extension/theme';

interface IProps {
onPreviousClick: () => void;
}
// types
import type { IScanQRCodeModalContentProps } from '@extension/types';

const ScanQRCodeModalScanningContent: FC<IProps> = ({
const ScanQRCodeViaTabModalContent: FC<IScanQRCodeModalContentProps> = ({
onPreviousClick,
}: IProps) => {
onURI,
}) => {
const { t } = useTranslation();
// hooks
const {
resetAction: resetScanAction,
startScanningAction,
uri,
} = useCaptureQRCode();
const defaultTextColor: string = useDefaultTextColor();
const primaryColor: string = useColorModeValue(
theme.colors.primaryLight['500'],
theme.colors.primaryDark['500']
);
// handlers
const handlePreviousClick = () => onPreviousClick();
const handlePreviousClick = () => {
onPreviousClick();
resetScanAction();
};

useEffect(() => {
startScanningAction({
mode: ScanModeEnum.Tab,
});
}, []);
useEffect(() => {
if (uri) {
onURI(uri);

// stop scanning
resetScanAction();
}
}, [uri]);

return (
<ModalContent
Expand Down Expand Up @@ -95,4 +121,4 @@ const ScanQRCodeModalScanningContent: FC<IProps> = ({
);
};

export default ScanQRCodeModalScanningContent;
export default ScanQRCodeViaTabModalContent;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ScanQRCodeViaTabModalContent';
4 changes: 3 additions & 1 deletion src/extension/enums/ErrorCodeEnum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ enum ErrorCodeEnum {
InvalidABIContractError = 5000,
ReadABIContractError = 5001,

// devices
// camera
CameraError = 6000,
CameraNotAllowedError = 6001,
CameraNotFoundError = 6002,
}

export default ErrorCodeEnum;
6 changes: 6 additions & 0 deletions src/extension/enums/ScanModeEnum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
enum ScanModeEnum {
Camera = 'camera',
Tab = 'tab',
}

export default ScanModeEnum;
1 change: 1 addition & 0 deletions src/extension/enums/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { default as NetworksThunkEnum } from './NetworksThunkEnum';
export { default as NetworkTypeEnum } from './NetworkTypeEnum';
export { default as PasswordLockThunkEnum } from './PasswordLockThunkEnum';
export { default as RegisterThunkEnum } from './RegisterThunkEnum';
export { default as ScanModeEnum } from './ScanModeEnum';
export { default as SendAssetsThunkEnum } from './SendAssetsThunkEnum';
export { default as SessionsThunkEnum } from './SessionsThunkEnum';
export { default as SettingsThunkEnum } from './SettingsThunkEnum';
Expand Down
10 changes: 10 additions & 0 deletions src/extension/errors/CameraNotAllowedError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// enums
import { ErrorCodeEnum } from '../enums';

// errors
import BaseExtensionError from './BaseExtensionError';

export default class CameraNotAllowedError extends BaseExtensionError {
public readonly code: ErrorCodeEnum = ErrorCodeEnum.CameraNotAllowedError;
public readonly name: string = 'CameraNotAllowedError';
}
10 changes: 10 additions & 0 deletions src/extension/errors/CameraNotFoundError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// enums
import { ErrorCodeEnum } from '../enums';

// errors
import BaseExtensionError from './BaseExtensionError';

export default class CameraNotFoundError extends BaseExtensionError {
public readonly code: ErrorCodeEnum = ErrorCodeEnum.CameraNotFoundError;
public readonly name: string = 'CameraNotFoundError';
}
2 changes: 2 additions & 0 deletions src/extension/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { default as BaseExtensionError } from './BaseExtensionError';
export { default as CameraError } from './CameraError';
export { default as CameraNotAllowedError } from './CameraNotAllowedError';
export { default as CameraNotFoundError } from './CameraNotFoundError';
export { default as DecryptionError } from './DecryptionError';
export { default as EncryptionError } from './EncryptionError';
export { default as FailedToSendTransactionError } from './FailedToSendTransactionError';
Expand Down
1 change: 1 addition & 0 deletions src/extension/hooks/useCameraStream/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './useCameraStream';
13 changes: 13 additions & 0 deletions src/extension/hooks/useCameraStream/types/IUseCameraStreamState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// errors
import { BaseExtensionError } from '@extension/errors';

interface IUseCameraStreamState {
error: BaseExtensionError | null;
loading: boolean;
reset: () => void;
startStream: () => Promise<void>;
stopStream: () => void;
stream: MediaStream | null;
}

export default IUseCameraStreamState;
1 change: 1 addition & 0 deletions src/extension/hooks/useCameraStream/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { default as IUseCameraStreamState } from './IUseCameraStreamState';
Loading

0 comments on commit 252b07b

Please sign in to comment.