An ATproto social media client -- with an independent Appview.
at main 224 lines 7.1 kB view raw
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}