An ATproto social media client -- with an independent Appview.
1import {Suspense, useRef, useState} from 'react'
2import {View} from 'react-native'
3import type ViewShot from 'react-native-view-shot'
4import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
5import {createAssetAsync} from 'expo-media-library'
6import * as Sharing from 'expo-sharing'
7import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
8import {msg, Trans} from '@lingui/macro'
9import {useLingui} from '@lingui/react'
10
11import {logger} from '#/logger'
12import {isNative, isWeb} from '#/platform/detection'
13import {atoms as a, useBreakpoints} from '#/alf'
14import {Button, ButtonIcon, ButtonText} from '#/components/Button'
15import * as Dialog from '#/components/Dialog'
16import {type DialogControlProps} from '#/components/Dialog'
17import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
18import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
19import {FloppyDisk_Stroke2_Corner0_Rounded as FloppyDiskIcon} from '#/components/icons/FloppyDisk'
20import {Loader} from '#/components/Loader'
21import {QrCode} from '#/components/StarterPack/QrCode'
22import * as Toast from '#/components/Toast'
23import * as bsky from '#/types/bsky'
24
25export function QrCodeDialog({
26 starterPack,
27 link,
28 control,
29}: {
30 starterPack: AppBskyGraphDefs.StarterPackView
31 link?: string
32 control: DialogControlProps
33}) {
34 const {_} = useLingui()
35 const {gtMobile} = useBreakpoints()
36 const [isSaveProcessing, setIsSaveProcessing] = useState(false)
37 const [isCopyProcessing, setIsCopyProcessing] = useState(false)
38
39 const ref = useRef<ViewShot>(null)
40
41 const getCanvas = (base64: string): Promise<HTMLCanvasElement> => {
42 return new Promise(resolve => {
43 const image = new Image()
44 image.onload = () => {
45 const canvas = document.createElement('canvas')
46 canvas.width = image.width
47 canvas.height = image.height
48
49 const ctx = canvas.getContext('2d')
50 ctx?.drawImage(image, 0, 0)
51 resolve(canvas)
52 }
53 image.src = base64
54 })
55 }
56
57 const onSavePress = async () => {
58 ref.current?.capture?.().then(async (uri: string) => {
59 if (isNative) {
60 const res = await requestMediaLibraryPermissionsAsync()
61
62 if (!res.granted) {
63 Toast.show(
64 _(
65 msg`You must grant access to your photo library to save a QR code`,
66 ),
67 )
68 return
69 }
70
71 // Incase of a FS failure, don't crash the app
72 try {
73 await createAssetAsync(`file://${uri}`)
74 } catch (e: unknown) {
75 Toast.show(_(msg`An error occurred while saving the QR code!`), {
76 type: 'error',
77 })
78 logger.error('Failed to save QR code', {error: e})
79 return
80 }
81 } else {
82 setIsSaveProcessing(true)
83
84 if (
85 !bsky.validate(
86 starterPack.record,
87 AppBskyGraphStarterpack.validateRecord,
88 )
89 ) {
90 return
91 }
92
93 const canvas = await getCanvas(uri)
94 const imgHref = canvas
95 .toDataURL('image/png')
96 .replace('image/png', 'image/octet-stream')
97
98 const link = document.createElement('a')
99 link.setAttribute(
100 'download',
101 `${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`,
102 )
103 link.setAttribute('href', imgHref)
104 link.click()
105 }
106
107 logger.metric('starterPack:share', {
108 starterPack: starterPack.uri,
109 shareType: 'qrcode',
110 qrShareType: 'save',
111 })
112 setIsSaveProcessing(false)
113 Toast.show(
114 isWeb
115 ? _(msg`QR code has been downloaded!`)
116 : _(msg`QR code saved to your camera roll!`),
117 )
118 control.close()
119 })
120 }
121
122 const onCopyPress = async () => {
123 setIsCopyProcessing(true)
124 ref.current?.capture?.().then(async (uri: string) => {
125 const canvas = await getCanvas(uri)
126 // @ts-expect-error web only
127 canvas.toBlob((blob: Blob) => {
128 const item = new ClipboardItem({'image/png': blob})
129 navigator.clipboard.write([item])
130 })
131
132 logger.metric('starterPack:share', {
133 starterPack: starterPack.uri,
134 shareType: 'qrcode',
135 qrShareType: 'copy',
136 })
137 Toast.show(_(msg`QR code copied to your clipboard!`))
138 setIsCopyProcessing(false)
139 control.close()
140 })
141 }
142
143 const onSharePress = async () => {
144 ref.current?.capture?.().then(async (uri: string) => {
145 control.close(() => {
146 Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then(
147 () => {
148 logger.metric('starterPack:share', {
149 starterPack: starterPack.uri,
150 shareType: 'qrcode',
151 qrShareType: 'share',
152 })
153 },
154 )
155 })
156 })
157 }
158
159 return (
160 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
161 <Dialog.Handle />
162 <Dialog.ScrollableInner
163 label={_(msg`Create a QR code for a starter pack`)}>
164 <View style={[a.flex_1, a.align_center, a.gap_5xl]}>
165 <Suspense fallback={<Loading />}>
166 {!link ? (
167 <Loading />
168 ) : (
169 <>
170 <QrCode starterPack={starterPack} link={link} ref={ref} />
171 <View
172 style={[
173 a.w_full,
174 a.gap_md,
175 gtMobile && [a.flex_row, a.justify_center, a.flex_wrap],
176 ]}>
177 <Button
178 label={_(msg`Copy QR code`)}
179 color="primary_subtle"
180 size="large"
181 onPress={isWeb ? onCopyPress : onSharePress}>
182 <ButtonIcon
183 icon={
184 isCopyProcessing
185 ? Loader
186 : isWeb
187 ? ChainLinkIcon
188 : ShareIcon
189 }
190 />
191 <ButtonText>
192 {isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>}
193 </ButtonText>
194 </Button>
195 <Button
196 label={_(msg`Save QR code`)}
197 color="secondary"
198 size="large"
199 onPress={onSavePress}>
200 <ButtonIcon
201 icon={isSaveProcessing ? Loader : FloppyDiskIcon}
202 />
203 <ButtonText>
204 <Trans>Save</Trans>
205 </ButtonText>
206 </Button>
207 </View>
208 </>
209 )}
210 </Suspense>
211 </View>
212 <Dialog.Close />
213 </Dialog.ScrollableInner>
214 </Dialog.Outer>
215 )
216}
217
218function Loading() {
219 return (
220 <View style={[a.align_center, a.justify_center, {minHeight: 400}]}>
221 <Loader size="xl" />
222 </View>
223 )
224}