Skip to content

Commit

Permalink
Feat: add qr code intake methods (#38)
Browse files Browse the repository at this point in the history
  • Loading branch information
dblackstone1 authored Oct 31, 2024
1 parent 8b125b1 commit 2a581fb
Show file tree
Hide file tree
Showing 14 changed files with 320 additions and 63 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@types/react-dom": "~18.2.22",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-react": "~4.2.1",
"@zxing/browser": "^0.1.5",
"autoprefixer": "~10.4.19",
"bignumber.js": "^9.1.2",
"bip39": "^3.1.0",
Expand All @@ -39,6 +40,7 @@
"qrcode.react": "^4.0.1",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"react-qr-reader": "^3.0.0-beta-1",
"react-router-dom": "~6.22.3",
"react-spring": "^9.7.4",
"react-use-gesture": "^9.1.3",
Expand Down
12 changes: 2 additions & 10 deletions public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,8 @@
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"host_permissions": [
"https://*/*",
"http://*/*"
],
"permissions": [
"activeTab",
"tabs",
"storage",
"clipboardWrite"
],
"host_permissions": ["https://*/*", "http://*/*"],
"permissions": ["activeTab", "videoCapture", "camera", "tabs", "storage", "clipboardWrite"],
"web_accessible_resources": [
{
"resources": ["index.html"],
Expand Down
10 changes: 3 additions & 7 deletions src/components/QRCodeContainer/QRCodeContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,21 @@ interface QRCodeContainerProps {

export const QRCodeContainer: React.FC<QRCodeContainerProps> = ({ qrCodeValue }) => {
return (
<div className="relative flex justify-center items-center bg-background-black rounded-lg w-[255px] h-[255px]">
{/* Top-left corner */}
<div className="relative flex justify-center items-center bg-background-black rounded-lg w-[225px] h-[225px]">
{/* Decorative Borders */}
<div className="absolute top-[-0px] left-[-0px] w-[75px] h-[75px] border-t-4 border-l-4 border-blue rounded-tl-[8px]" />
{/* Top-right corner */}
<div className="absolute top-[-0px] right-[-0px] w-[75px] h-[75px] border-t-4 border-r-4 border-blue rounded-tr-[8px]" />
{/* Bottom-left corner */}
<div className="absolute bottom-[-0px] left-[-0px] w-[75px] h-[75px] border-b-4 border-l-4 border-blue rounded-bl-[8px]" />
{/* Bottom-right corner */}
<div className="absolute bottom-[-0px] right-[-0px] w-[75px] h-[75px] border-b-4 border-r-4 border-blue rounded-br-[8px]" />

{/* Blue border around the image */}
<div className="absolute inset-0 flex justify-center items-center">
<div className="w-[71px] h-[71px] border-2 border-blue rounded-md" />
</div>

{/* QR Code */}
<QRCodeSVG
value={qrCodeValue}
size={250}
size={215}
bgColor="#FFFFFF"
fgColor="#000000"
level="Q"
Expand Down
204 changes: 204 additions & 0 deletions src/components/QRCodeScannerDialog/QRCodeScannerDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import React, { useEffect, useRef, useState } from 'react';
import { SlideTray, Button } from '@/ui-kit';
import { useAtomValue, useSetAtom } from 'jotai';
import { filteredAssetsAtom, recipientAddressAtom } from '@/atoms';
import { QRCode } from '@/assets/icons';
import { QrReader } from 'react-qr-reader';
import { BrowserQRCodeReader } from '@zxing/browser';
import { cn } from '@/helpers';
import { Asset } from '@/types';

interface QRCodeScannerDialogProps {
updateSendAsset: (asset: Asset, propagateChanges: boolean) => void;
}

export const QRCodeScannerDialog: React.FC<QRCodeScannerDialogProps> = ({ updateSendAsset }) => {
const slideTrayRef = useRef<{ isOpen: () => void; closeWithAnimation: () => void }>(null);

const setAddress = useSetAtom(recipientAddressAtom);
const filteredAssets = useAtomValue(filteredAssetsAtom);

const [permissionDenied, setPermissionDenied] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);

const qrCodeReader = new BrowserQRCodeReader();
const slideTrayIsOpen = slideTrayRef.current && slideTrayRef.current.isOpen();

const handleScan = (result: string | null) => {
if (result) {
console.log('QR Code Scanned:', result);

try {
const parsedResult = JSON.parse(result);
if (parsedResult.address && parsedResult.denomPreference) {
console.log('parsed result', parsedResult);
const preferredAsset = filteredAssets.find(
asset => asset.denom === parsedResult.denomPreference,
);
console.log('preferred asset', preferredAsset);

setAddress(parsedResult.address);
updateSendAsset(preferredAsset as Asset, true);
} else {
setAddress(result);
}
} catch (err) {
setAddress(result);
}

slideTrayRef.current?.closeWithAnimation();
}
};

const handleError = (error: any) => {
console.error('QR Scanner Error:', error);
if (error.name === 'NotAllowedError') {
console.log('Please grant camera permissions to use the QR scanner.');
setPermissionDenied(true);
}
};

const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
try {
const url = URL.createObjectURL(file);
const result = await qrCodeReader.decodeFromImageUrl(url);
handleScan(result.getText());
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error scanning file:', error);
}
}
};

const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
if (event.dataTransfer.files && event.dataTransfer.files[0]) {
const file = event.dataTransfer.files[0];
const url = URL.createObjectURL(file);
try {
const result = await qrCodeReader.decodeFromImageUrl(url);
handleScan(result.getText());
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error scanning file:', error);
}
}
};

const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(true);
};

const handleDragLeave = () => {
setIsDragOver(false);
};

const requestCameraPermission = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach(track => track.stop());
setPermissionDenied(false);
console.log('Camera access granted.');
} catch (error) {
console.error('Permission error:', error);
setPermissionDenied(true);
}
};

useEffect(() => {
if (slideTrayIsOpen && !permissionDenied) {
requestCameraPermission();
}
}, [slideTrayIsOpen]);

const borderColor = isDragOver
? 'border-blue-pressed'
: slideTrayIsOpen && !permissionDenied
? 'border-blue'
: 'border-neutral-3';

return (
<SlideTray
ref={slideTrayRef}
triggerComponent={
<QRCode
className="h-7 w-7 text-neutral-1 hover:bg-blue-hover hover:text-blue-dark cursor-pointer"
width={20}
/>
}
title="Scan Address"
showBottomBorder
>
<div className="flex flex-col items-center space-yt-4 yb-2">
{/* Camera View & Drag/Drop Area */}
<div
className={`relative flex justify-center items-center bg-background-black rounded-lg w-[255px] h-[255px]`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{/* Decorative Borders */}
<div
className={cn(
`absolute top-[-0px] left-[-0px] w-[75px] h-[75px] border-t-4 border-l-4 ${borderColor} rounded-tl-[8px]`,
)}
/>
<div
className={cn(
`absolute top-[-0px] right-[-0px] w-[75px] h-[75px] border-t-4 border-r-4 ${borderColor} rounded-tr-[8px]`,
)}
/>
<div
className={cn(
`absolute bottom-[-0px] left-[-0px] w-[75px] h-[75px] border-b-4 border-l-4 ${borderColor} rounded-bl-[8px]`,
)}
/>
<div
className={cn(
`absolute bottom-[-0px] right-[-0px] w-[75px] h-[75px] border-b-4 border-r-4 ${borderColor} rounded-br-[8px]`,
)}
/>

{slideTrayIsOpen ? (
<QrReader
onResult={(result, error) => {
if (result) handleScan(result.getText());
if (error) handleError(error);
}}
constraints={{ facingMode: 'environment' }}
className="w-[96.8%] h-[100%]"
/>
) : (
<p className="text-gray-dark text-center">Enable camera or add file</p>
)}
</div>

{/* File Explorer Button */}
<Button
variant="secondary"
size="small"
onClick={() => {
const fileInput = document.getElementById('qr-file-input') as HTMLInputElement;
fileInput?.click();
}}
className="mt-3 px-4 py-1"
>
Use File
</Button>

{/* Hidden File Input */}
<input
id="qr-file-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
</div>
</SlideTray>
);
};
1 change: 1 addition & 0 deletions src/components/QRCodeScannerDialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './QRCodeScannerDialog';
29 changes: 25 additions & 4 deletions src/components/ReceiveDialog/ReceiveDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import React from 'react';
import React, { useState } from 'react';
import { useAtomValue } from 'jotai';
import { walletStateAtom } from '@/atoms';
import { Button, CopyTextField, SlideTray } from '@/ui-kit';
import { truncateWalletAddress } from '@/helpers';
import { WALLET_PREFIX } from '@/constants';
import { DEFAULT_ASSET, WALLET_PREFIX } from '@/constants';
import { QRCodeContainer } from '../QRCodeContainer';

export const ReceiveDialog: React.FC = () => {
const walletState = useAtomValue(walletStateAtom);
const walletAddress = walletState.address;

const [includeCoinPreference, setIncludeCoinPreference] = useState(false);

const walletAddress = walletState.address;
const walletDisplayAddress = truncateWalletAddress(WALLET_PREFIX, walletAddress);

const qrDataWithAddress = JSON.stringify({
address: walletAddress,
denomPreference: DEFAULT_ASSET.denom,
});
const qrData = includeCoinPreference ? qrDataWithAddress : walletAddress;

return (
<SlideTray
triggerComponent={
Expand All @@ -21,9 +29,22 @@ export const ReceiveDialog: React.FC = () => {
}
title="Copy Address"
showBottomBorder
reducedTopMargin
>
<div className="flex flex-col items-center">
<QRCodeContainer qrCodeValue={walletAddress} />
<div className="mb-2">
Maestro only:{' '}
<Button
variant={!includeCoinPreference ? 'unselected' : 'selectedEnabled'}
size="small"
onClick={() => setIncludeCoinPreference(!includeCoinPreference)}
className="px-2 rounded-md text-xs"
>
{`${includeCoinPreference ? 'Remove' : 'Include'} coin preference`}
</Button>
</div>

<QRCodeContainer qrCodeValue={qrData} />

{/* Wallet Address */}
<CopyTextField
Expand Down
1 change: 0 additions & 1 deletion src/components/ValidatorScrollTile/ValidatorScrollTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ export const ValidatorScrollTile = ({

const handleTransactionSuccess = (transactionType: TransactionType, txHash: string) => {
const slideTrayIsOpen = slideTrayRef.current && slideTrayRef.current.isOpen();
console.log('reference slide tray is open?', slideTrayIsOpen);

if (slideTrayIsOpen) {
setSelectedAction(null);
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './CreatePasswordForm';
export * from './Loader';
export * from './OptionsDialog';
export * from './QRCodeContainer';
export * from './QRCodeScannerDialog';
export * from './ReceiveDialog';
export * from './RecoveryPhraseGrid';
export * from './ScrollTile';
Expand Down
27 changes: 12 additions & 15 deletions src/pages/auth/CreateWallet/CreateWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,28 +103,25 @@ export const CreateWallet = () => {

/* ******************************************************************************************* */
/* David Current TODOs */
// TODO: modify toast display. may need to force consistent refresh of toast to modify this
// TODO: Add QR code intake methods (camera, file selection/drag and drop option)
// TODO: Add Maestro-only QR code that also shows preferred receive asset (requires preferred receive asset)

// TODO: put Loader on loading screen, not "loading"
// TODO: make "clear" and "max" button send screen inputs. make placement and appearance for these uniform (send and unstake sections)
// TODO: pull unbonding days dynamically from the validator
// TODO: amend fee showing as 0 rather than 0 MLD (send page0)
// TODO: add fee display and updates for stake, unstake, and claim

// TODO: make "clear" and "max" button placement and appearance more uniform (send and unstake sections)
// TODO: add search icon to search field, add onclick

// TODO: clean up helper functions and hooks
/* ******************************************************************************************* */

/* Current TODOs */
// TODO: keep track of current page for case of re-open before timeout
// TODO: ensure logout after blur (click outside application to close). to remove sensitive data after time period
// TODO: prevent re-building auth every time wallet updates

/* Less Critical Auth TODOs */
// TODO: ensure new encrypted mnemonic overwrites old in case of same password and name (but let user know first)
// TODO: ensure logout after blur + timeout (blur is click outside application to close). to remove sensitive data after time period
// TODO: test path and create error for no wallet exists and user attempts login
// TODO: ensure new encrypted mnemonic overwrites old in case of same password and name (but let user know first)
// TODO: handle error printout for create/import wallet (in place of subtitle on verify screen?)
// TODO: modify auth to accounts & wallets structure to make this scalable for later upgrades

/* Less Critical TODOs */
// TODO: keep track of current page for case of re-open after timeout
// TODO: clean up helper functions and hooks
// TODO: prevent re-building auth every time wallet updates
// TODO: add search icon to search field (component), add onclick

/* Interchain-compatibility TODOs (mobile version before this) */
// TODO: add button to "add chain" at bottom of Holdings list
Expand Down
Loading

0 comments on commit 2a581fb

Please sign in to comment.