Skip to content

Commit

Permalink
Show paste button in qr scanner dialog (#4309)
Browse files Browse the repository at this point in the history
Show paste button in qr scanner dialog

resolves #3936

* Replace userFeedback with AlertDialog
  • Loading branch information
nicodh authored Nov 21, 2024
1 parent 7b3c666 commit 9685b50
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 160 deletions.
3 changes: 3 additions & 0 deletions _locales/_untranslated_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,8 @@
},
"react_more_emojis": {
"message": "More emojis…"
},
"paste": {
"message": "Paste"
}
}
8 changes: 1 addition & 7 deletions packages/e2e-tests/tests/basic-functionality.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,7 @@ test('start chat with user', async ({ page, context, browserName }) => {

await page.getByTestId('show-qr-scan').click()

await page.getByTestId('qr-reader-settings').click()

const item = page.getByTestId('paste-from-clipboard')

expect(await item.isVisible()).toBeTruthy()

await item.click()
await page.getByTestId('paste').click()

const t = await page
.locator('.styles_module_dialog')
Expand Down
88 changes: 88 additions & 0 deletions packages/frontend/src/components/QrReader/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import scanQrCode, { QRCode } from 'jsqr'
import { Runtime } from '@deltachat-desktop/runtime-interface'

/**
* Convert file data to base64 encoded data URL string.
*/
export async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()

try {
reader.addEventListener(
'load',
() => {
resolve(reader.result as string)
},
false
)

reader.readAsDataURL(file)
} catch (error) {
reject(error)
}
})
}

/**
* Convert base64-encoded blob string into image data.
*/
export async function base64ToImageData(base64: string): Promise<ImageData> {
return new Promise((resolve, reject) => {
const image = new Image()

image.addEventListener('load', () => {
try {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = image.width
canvas.height = image.height

if (!context) {
return
}

context.drawImage(image, 0, 0)

const imageData = context.getImageData(0, 0, image.width, image.height)
resolve(imageData)
} catch (error) {
reject(error)
}
})

image.src = base64
})
}

/**
* @throws Error (no data in clipboard)
*/
export async function qrCodeFromClipboard(runtime: Runtime): Promise<string> {
// Try interpreting the clipboard data as an image
const base64 = await runtime.readClipboardImage()
if (base64) {
const imageData = await base64ToImageData(base64)
const result = scanQrCode(imageData.data, imageData.width, imageData.height)
if (result?.data) {
return result.data
} else {
throw new Error('no data in clipboard image')
}
}

// .. otherwise return non-image data from clipboard directly
const data = await runtime.readClipboardText()
if (!data) {
throw new Error('no data in clipboard')
}
// trim whitespaces because user might copy them by accident when sending over other messengers
// see https://github.com/deltachat/deltachat-desktop/issues/4161#issuecomment-2390428338
return data.trim()
}

export async function qrCodeFromImage(file: File): Promise<QRCode | null> {
const base64 = await fileToBase64(file)
const imageData = await base64ToImageData(base64)
return scanQrCode(imageData.data, imageData.width, imageData.height)
}
158 changes: 23 additions & 135 deletions packages/frontend/src/components/QrReader/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import React, {
useRef,
useState,
} from 'react'
import scanQrCode from 'jsqr'
import classNames from 'classnames'

import Icon from '../Icon'
import Spinner from '../Spinner'
import useTranslationFunction from '../../hooks/useTranslationFunction'
import { ContextMenuContext } from '../../contexts/ContextMenuContext'
import { ScreenContext } from '../../contexts/ScreenContext'
import { runtime } from '@deltachat-desktop/runtime-interface'

import { qrCodeFromImage, qrCodeFromClipboard } from './helper'

// @ts-ignore:next-line: We're importing a worker here with the help of the
// "esbuild-plugin-inline-worker" plugin
import Worker from './qr.worker'
Expand All @@ -26,7 +26,7 @@ import { mouseEventToPosition } from '../../utils/mouseEventToPosition'

type Props = {
onError: (error: string) => void
onScan: (data: string) => void
onScanSuccess: (data: string) => void
}

type ImageDimensions = {
Expand All @@ -38,71 +38,16 @@ const SCAN_QR_INTERVAL_MS = 250

const worker = new Worker()

/**
* Convert file data to base64 encoded data URL string.
*/
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()

try {
reader.addEventListener(
'load',
() => {
resolve(reader.result as string)
},
false
)

reader.readAsDataURL(file)
} catch (error) {
reject(error)
}
})
}

/**
* Convert base64-encoded blob string into image data.
*/
async function base64ToImageData(base64: string): Promise<ImageData> {
return new Promise((resolve, reject) => {
const image = new Image()

image.addEventListener('load', () => {
try {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = image.width
canvas.height = image.height

if (!context) {
return
}

context.drawImage(image, 0, 0)

const imageData = context.getImageData(0, 0, image.width, image.height)
resolve(imageData)
} catch (error) {
reject(error)
}
})

image.src = base64
})
}

export default function QrReader({ onError, onScan }: Props) {
export default function QrReader({ onError, onScanSuccess }: Props) {
const tx = useTranslationFunction()
const { openContextMenu } = useContext(ContextMenuContext)
const { userFeedback } = useContext(ScreenContext)

const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const inputRef = useRef<HTMLInputElement>(null)

const [ready, setReady] = useState(false)
const [error, setError] = useState(false)
const [cameraAccessError, setCameraAccessError] = useState(false)
const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([])
const [deviceId, setDeviceId] = useState<string | undefined>(undefined)
const [dimensions, setDimensions] = useState<ImageDimensions>({
Expand All @@ -129,27 +74,6 @@ export default function QrReader({ onError, onScan }: Props) {
getAllCameras()
}, [])

// General handler for scanning results coming from the "jsqr" library.
//
// Additionally we have checks in place to make sure we're not firing any
// callbacks when this React component has already been unmounted.
const handleScanResult = useCallback(
result => {
let unmounted = false

if (unmounted) {
return
}

onScan(result.data)

return () => {
unmounted = true
}
},
[onScan]
)

// General handler for errors which might occur during scanning.
const handleError = useCallback(
(error: any) => {
Expand All @@ -158,47 +82,18 @@ export default function QrReader({ onError, onScan }: Props) {
} else {
onError(error.toString())
}

setError(true)
},
[onError]
)

// Read data from clipboard which potentially can be an image itself.
const handlePasteFromClipboard = useCallback(async () => {
try {
// Try interpreting the clipboard data as an image
const base64 = await runtime.readClipboardImage()
if (base64) {
const imageData = await base64ToImageData(base64)
const result = scanQrCode(
imageData.data,
imageData.width,
imageData.height
)
if (result) {
handleScanResult(result)
return
} else {
throw new Error('no data in clipboard image')
}
}

// .. otherwise return non-image data from clipboard directly
const data = await runtime.readClipboardText()
if (!data) {
throw new Error('no data in clipboard')
}
// trim whitespaces because user might copy them by accident when sending over other messengers
// see https://github.com/deltachat/deltachat-desktop/issues/4161#issuecomment-2390428338
onScan(data.trim())
const result = await qrCodeFromClipboard(runtime)
onScanSuccess(result)
} catch (error) {
userFeedback({
type: 'error',
text: `${tx('qrscan_failed')}: ${error}`,
})
handleError(error)
}
}, [handleScanResult, onScan, tx, userFeedback])
}, [onScanSuccess, handleError])

// Read data from an external image file.
//
Expand All @@ -220,21 +115,12 @@ export default function QrReader({ onError, onScan }: Props) {

try {
// Convert file to correct image data and scan it
const base64 = await fileToBase64(file)
const imageData = await base64ToImageData(base64)
const result = scanQrCode(
imageData.data,
imageData.width,
imageData.height
)
const result = await qrCodeFromImage(file)

if (result) {
handleScanResult(result)
onScanSuccess(result.data)
} else {
userFeedback({
type: 'error',
text: `${tx('qrscan_failed')}: no data in image`,
})
throw Error(`no data in image`)
}
} catch (error: any) {
handleError(error)
Expand All @@ -246,7 +132,7 @@ export default function QrReader({ onError, onScan }: Props) {
inputRef.current.value = ''
}
},
[handleError, handleScanResult, tx, userFeedback]
[handleError, onScanSuccess]
)

// Show a context menu with different video input options to the user.
Expand Down Expand Up @@ -359,7 +245,7 @@ export default function QrReader({ onError, onScan }: Props) {
setReady(true)
} catch {
stopStream(activeStream)
setError(true)
setCameraAccessError(true)
}
}

Expand All @@ -375,7 +261,7 @@ export default function QrReader({ onError, onScan }: Props) {
stopStream(activeStream)

setReady(false)
setError(false)
setCameraAccessError(false)
}
}, [deviceId])

Expand All @@ -389,7 +275,7 @@ export default function QrReader({ onError, onScan }: Props) {

const handleWorkerMessage = (event: MessageEvent) => {
if (event.data) {
handleScanResult(event)
onScanSuccess(event.data)
}
}

Expand Down Expand Up @@ -431,7 +317,7 @@ export default function QrReader({ onError, onScan }: Props) {
worker.removeEventListener('message', handleWorkerMessage)
window.clearInterval(interval)
}
}, [handleError, handleScanResult, onScan])
}, [handleError, onScanSuccess])

return (
<div className={styles.qrReader}>
Expand All @@ -446,22 +332,24 @@ export default function QrReader({ onError, onScan }: Props) {
/>
<video
className={classNames(styles.qrReaderVideo, {
[styles.visible]: ready && !error,
[styles.visible]: ready && !cameraAccessError,
})}
autoPlay
muted
disablePictureInPicture
playsInline
ref={videoRef}
/>
{error && (
{cameraAccessError && (
<div className={classNames(styles.qrReaderStatus, styles.error)}>
{tx('camera_access_failed')}
</div>
)}
<div className={styles.qrReaderOverlay} />
{ready && !error && <div className={styles.qrReaderScanLine} />}
{!error && (
{ready && !cameraAccessError && (
<div className={styles.qrReaderScanLine} />
)}
{!cameraAccessError && (
<div className={styles.qrReaderHint}>{tx('qrscan_hint_desktop')}</div>
)}
<button
Expand Down
Loading

0 comments on commit 9685b50

Please sign in to comment.