Bluesky app fork with some witchin' additions 💫

WIP, avi not working on web

+266 -176
+247 -176
src/components/dialogs/nudges/TenMillion.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 import ViewShot from 'react-native-view-shot' 4 + import {Image} from 'expo-image' 4 5 import {moderateProfile} from '@atproto/api' 5 6 import {msg, Trans} from '@lingui/macro' 6 7 import {useLingui} from '@lingui/react' 7 8 9 + import {getCanvas} from '#/lib/canvas' 8 10 import {sanitizeDisplayName} from '#/lib/strings/display-names' 9 11 import {sanitizeHandle} from '#/lib/strings/handles' 10 12 import {isNative} from '#/platform/detection' ··· 32 34 import {Loader} from '#/components/Loader' 33 35 import {Text} from '#/components/Typography' 34 36 37 + const DEBUG = false 35 38 const RATIO = 8 / 10 36 39 const WIDTH = 2000 37 40 const HEIGHT = WIDTH * RATIO ··· 47 50 } 48 51 } 49 52 53 + function Frame({children}: {children: React.ReactNode}) { 54 + return ( 55 + <View 56 + style={[ 57 + a.relative, 58 + a.w_full, 59 + a.overflow_hidden, 60 + { 61 + paddingTop: '80%', 62 + }, 63 + ]}> 64 + {children} 65 + </View> 66 + ) 67 + } 68 + 50 69 export function TenMillion() { 51 70 const t = useTheme() 52 71 const lightTheme = useTheme('light') ··· 54 73 const {controls} = useContext() 55 74 const {gtMobile} = useBreakpoints() 56 75 const {openComposer} = useComposerControls() 57 - const imageRef = React.useRef<ViewShot>(null) 58 76 const {currentAccount} = useSession() 59 77 const {isLoading: isProfileLoading, data: profile} = useProfileQuery({ 60 78 did: currentAccount!.did, ··· 65 83 ? moderateProfile(profile, moderationOpts) 66 84 : undefined 67 85 }, [profile, moderationOpts]) 86 + const [uri, setUri] = React.useState<string | null>(null) 68 87 69 - const isLoading = isProfileLoading || !moderation || !profile 88 + const isLoadingData = isProfileLoading || !moderation || !profile 89 + const isLoadingImage = !uri 70 90 71 - const userNumber = 56738 91 + const userNumber = 56738 // TODO 92 + 93 + const captureInProgress = React.useRef(false) 94 + const imageRef = React.useRef<ViewShot>(null) 72 95 73 96 const share = () => { 74 - if (imageRef.current && imageRef.current.capture) { 75 - imageRef.current.capture().then(uri => { 76 - controls.tenMillion.close(() => { 77 - setTimeout(() => { 78 - openComposer({ 79 - text: '10 milly, babyyy', 80 - imageUris: [ 81 - { 82 - uri, 83 - width: WIDTH, 84 - height: HEIGHT, 85 - }, 86 - ], 87 - }) 88 - }, 1e3) 89 - }) 97 + if (uri) { 98 + controls.tenMillion.close(() => { 99 + setTimeout(() => { 100 + openComposer({ 101 + text: '10 milly, babyyy', 102 + imageUris: [ 103 + { 104 + uri, 105 + width: WIDTH, 106 + height: HEIGHT, 107 + }, 108 + ], 109 + }) 110 + }, 1e3) 90 111 }) 91 112 } 92 113 } 93 114 94 - return ( 95 - <Dialog.Outer control={controls.tenMillion}> 96 - <Dialog.Handle /> 115 + const onCanvasReady = async () => { 116 + if ( 117 + imageRef.current && 118 + imageRef.current.capture && 119 + !captureInProgress.current 120 + ) { 121 + captureInProgress.current = true 122 + const uri = await imageRef.current.capture() 123 + setUri(uri) 124 + } 125 + } 126 + 127 + const download = async () => { 128 + if (uri) { 129 + const canvas = await getCanvas(uri) 130 + const imgHref = canvas 131 + .toDataURL('image/png') 132 + .replace('image/png', 'image/octet-stream') 133 + const link = document.createElement('a') 134 + link.setAttribute('download', `Bluesky 10M Users.png`) 135 + link.setAttribute('href', imgHref) 136 + link.click() 137 + } 138 + } 97 139 98 - <Dialog.ScrollableInner 99 - label={_(msg`Ten Million`)} 100 - style={[ 101 - { 102 - padding: 0, 103 - }, 104 - // gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, 105 - ]}> 106 - <View 107 - style={[ 108 - a.rounded_md, 109 - a.overflow_hidden, 110 - isNative && { 111 - borderTopLeftRadius: 40, 112 - borderTopRightRadius: 40, 140 + const canvas = isLoadingData ? null : ( 141 + <View 142 + style={[ 143 + a.absolute, 144 + a.overflow_hidden, 145 + DEBUG 146 + ? { 147 + width: 600, 148 + height: 600 * RATIO, 149 + } 150 + : { 151 + width: 1, 152 + height: 1, 113 153 }, 114 - ]}> 115 - <ThemeProvider theme="light"> 116 - <View 117 - style={[ 118 - a.relative, 119 - a.w_full, 120 - a.overflow_hidden, 121 - { 122 - paddingTop: '80%', 123 - }, 124 - ]}> 125 - <ViewShot 126 - ref={imageRef} 127 - options={{width: WIDTH, height: HEIGHT}} 128 - style={[a.absolute, a.inset_0]}> 154 + ]}> 155 + <View style={{width: 600}}> 156 + <ThemeProvider theme="light"> 157 + <Frame> 158 + <ViewShot 159 + ref={imageRef} 160 + options={{width: WIDTH, height: HEIGHT}} 161 + style={[a.absolute, a.inset_0]}> 162 + <View 163 + style={[ 164 + a.absolute, 165 + a.inset_0, 166 + a.align_center, 167 + a.justify_center, 168 + { 169 + top: -1, 170 + bottom: -1, 171 + left: -1, 172 + right: -1, 173 + paddingVertical: 32, 174 + paddingHorizontal: 48, 175 + }, 176 + ]}> 177 + <GradientFill gradient={tokens.gradients.bonfire} /> 178 + 129 179 <View 130 180 style={[ 131 - a.absolute, 132 - a.inset_0, 181 + a.flex_1, 182 + a.w_full, 133 183 a.align_center, 134 184 a.justify_center, 185 + a.rounded_md, 135 186 { 136 - top: -1, 137 - bottom: -1, 138 - left: -1, 139 - right: -1, 140 - paddingVertical: 32, 141 - paddingHorizontal: 48, 187 + backgroundColor: 'white', 188 + shadowRadius: 32, 189 + shadowOpacity: 0.1, 190 + elevation: 24, 191 + shadowColor: tokens.gradients.bonfire.values[0][1], 142 192 }, 143 193 ]}> 144 - <GradientFill gradient={tokens.gradients.bonfire} /> 194 + <View 195 + style={[ 196 + a.absolute, 197 + a.px_xl, 198 + a.py_xl, 199 + { 200 + top: 0, 201 + left: 0, 202 + }, 203 + ]}> 204 + <Logomark fill={t.palette.primary_500} width={36} /> 205 + </View> 145 206 146 - {isLoading ? ( 147 - <Loader size="xl" fill="white" /> 148 - ) : ( 149 - <View 207 + {/* Centered content */} 208 + <View 209 + style={[ 210 + { 211 + paddingBottom: 48, 212 + }, 213 + ]}> 214 + <Text 150 215 style={[ 151 - a.flex_1, 152 - a.w_full, 153 - a.align_center, 154 - a.justify_center, 155 - a.rounded_md, 216 + a.text_md, 217 + a.font_bold, 218 + a.text_center, 219 + a.pb_xs, 220 + lightTheme.atoms.text_contrast_medium, 221 + ]}> 222 + <Trans> 223 + Celebrating {formatCount(i18n, 10000000)} users 224 + </Trans>{' '} 225 + 🎉 226 + </Text> 227 + <Text 228 + style={[ 229 + a.relative, 230 + a.text_center, 156 231 { 157 - backgroundColor: 'white', 158 - shadowRadius: 32, 159 - shadowOpacity: 0.1, 160 - elevation: 24, 161 - shadowColor: tokens.gradients.bonfire.values[0][1], 232 + fontStyle: 'italic', 233 + fontSize: getFontSize(userNumber), 234 + fontWeight: '900', 235 + letterSpacing: -2, 162 236 }, 163 237 ]}> 164 - <View 238 + <Text 165 239 style={[ 166 240 a.absolute, 167 - a.px_xl, 168 - a.py_xl, 169 241 { 170 - top: 0, 171 - left: 0, 242 + color: t.palette.primary_500, 243 + fontSize: 32, 244 + left: -18, 245 + top: 8, 172 246 }, 173 247 ]}> 174 - <Logomark fill={t.palette.primary_500} width={36} /> 175 - </View> 248 + # 249 + </Text> 250 + {i18n.number(userNumber)} 251 + </Text> 252 + </View> 253 + {/* End centered content */} 176 254 177 - {/* Centered content */} 178 - <View 179 - style={[ 180 - { 181 - paddingBottom: 48, 182 - }, 183 - ]}> 184 - <Text 185 - style={[ 186 - a.text_md, 187 - a.font_bold, 188 - a.text_center, 189 - a.pb_xs, 190 - lightTheme.atoms.text_contrast_medium, 191 - ]}> 192 - <Trans> 193 - Celebrating {formatCount(i18n, 10000000)} users 194 - </Trans>{' '} 195 - 🎉 255 + <View 256 + style={[ 257 + a.absolute, 258 + a.px_xl, 259 + a.py_xl, 260 + { 261 + bottom: 0, 262 + left: 0, 263 + right: 0, 264 + }, 265 + ]}> 266 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 267 + <UserAvatar 268 + size={36} 269 + avatar={profile.avatar} 270 + moderation={moderation.ui('avatar')} 271 + onLoad={onCanvasReady} 272 + /> 273 + <View style={[a.gap_2xs, a.flex_1]}> 274 + <Text style={[a.text_sm, a.font_bold]}> 275 + {sanitizeDisplayName( 276 + profile.displayName || 277 + sanitizeHandle(profile.handle), 278 + moderation.ui('displayName'), 279 + )} 196 280 </Text> 197 - <Text 198 - style={[ 199 - a.relative, 200 - a.text_center, 201 - { 202 - fontStyle: 'italic', 203 - fontSize: getFontSize(userNumber), 204 - fontWeight: '900', 205 - letterSpacing: -2, 206 - }, 207 - ]}> 281 + <View style={[a.flex_row, a.justify_between]}> 208 282 <Text 209 283 style={[ 210 - a.absolute, 211 - { 212 - color: t.palette.primary_500, 213 - fontSize: 32, 214 - left: -18, 215 - top: 8, 216 - }, 284 + a.text_sm, 285 + a.font_semibold, 286 + lightTheme.atoms.text_contrast_medium, 217 287 ]}> 218 - # 288 + {sanitizeHandle(profile.handle, '@')} 219 289 </Text> 220 - {i18n.number(userNumber)} 221 - </Text> 222 - </View> 223 - {/* End centered content */} 224 290 225 - <View 226 - style={[ 227 - a.absolute, 228 - a.px_xl, 229 - a.py_xl, 230 - { 231 - bottom: 0, 232 - left: 0, 233 - right: 0, 234 - }, 235 - ]}> 236 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 237 - <UserAvatar 238 - size={36} 239 - avatar={profile.avatar} 240 - moderation={moderation.ui('avatar')} 241 - /> 242 - <View style={[a.gap_2xs, a.flex_1]}> 243 - <Text style={[a.text_sm, a.font_bold]}> 244 - {sanitizeDisplayName( 245 - profile.displayName || 246 - sanitizeHandle(profile.handle), 247 - moderation.ui('displayName'), 248 - )} 291 + {profile.createdAt && ( 292 + <Text 293 + style={[ 294 + a.text_sm, 295 + a.font_semibold, 296 + lightTheme.atoms.text_contrast_low, 297 + ]}> 298 + {i18n.date(profile.createdAt, { 299 + dateStyle: 'long', 300 + })} 249 301 </Text> 250 - <View style={[a.flex_row, a.justify_between]}> 251 - <Text 252 - style={[ 253 - a.text_sm, 254 - a.font_semibold, 255 - lightTheme.atoms.text_contrast_medium, 256 - ]}> 257 - {sanitizeHandle(profile.handle, '@')} 258 - </Text> 259 - 260 - {profile.createdAt && ( 261 - <Text 262 - style={[ 263 - a.text_sm, 264 - a.font_semibold, 265 - lightTheme.atoms.text_contrast_low, 266 - ]}> 267 - {i18n.date(profile.createdAt, { 268 - dateStyle: 'long', 269 - })} 270 - </Text> 271 - )} 272 - </View> 273 - </View> 302 + )} 274 303 </View> 275 304 </View> 276 305 </View> 277 - )} 306 + </View> 278 307 </View> 279 - </ViewShot> 308 + </View> 309 + </ViewShot> 310 + </Frame> 311 + </ThemeProvider> 312 + </View> 313 + </View> 314 + ) 315 + 316 + return ( 317 + <Dialog.Outer control={controls.tenMillion}> 318 + <Dialog.Handle /> 319 + 320 + <Dialog.ScrollableInner 321 + label={_(msg`Ten Million`)} 322 + style={[ 323 + { 324 + padding: 0, 325 + }, 326 + ]}> 327 + <View 328 + style={[ 329 + a.rounded_md, 330 + a.overflow_hidden, 331 + isNative && { 332 + borderTopLeftRadius: 40, 333 + borderTopRightRadius: 40, 334 + }, 335 + ]}> 336 + <Frame> 337 + <View 338 + style={[a.absolute, a.inset_0, a.align_center, a.justify_center]}> 339 + <GradientFill gradient={tokens.gradients.bonfire} /> 340 + {isLoadingData || isLoadingImage ? ( 341 + <Loader size="xl" fill="white" /> 342 + ) : ( 343 + <Image 344 + accessibilityIgnoresInvertColors 345 + source={{uri}} 346 + style={[a.w_full, a.h_full]} 347 + /> 348 + )} 280 349 </View> 281 - </ThemeProvider> 350 + </Frame> 351 + 352 + {canvas} 282 353 283 354 <View style={[gtMobile ? a.p_2xl : a.p_xl]}> 284 355 <Text ··· 321 392 variant="solid" 322 393 color="secondary" 323 394 shape="square" 324 - onPress={share}> 395 + onPress={download}> 325 396 <ButtonIcon icon={Share} /> 326 397 </Button> 327 398 <Button
+15
src/lib/canvas.ts
··· 1 + export const getCanvas = (base64: string): Promise<HTMLCanvasElement> => { 2 + return new Promise(resolve => { 3 + const image = new Image() 4 + image.onload = () => { 5 + const canvas = document.createElement('canvas') 6 + canvas.width = image.width 7 + canvas.height = image.height 8 + 9 + const ctx = canvas.getContext('2d') 10 + ctx?.drawImage(image, 0, 0) 11 + resolve(canvas) 12 + } 13 + image.src = base64 14 + }) 15 + }
+4
src/view/com/util/UserAvatar.tsx
··· 43 43 interface UserAvatarProps extends BaseUserAvatarProps { 44 44 moderation?: ModerationUI 45 45 usePlainRNImage?: boolean 46 + onLoad?: () => void 46 47 } 47 48 48 49 interface EditableUserAvatarProps extends BaseUserAvatarProps { ··· 174 175 avatar, 175 176 moderation, 176 177 usePlainRNImage = false, 178 + onLoad, 177 179 }: UserAvatarProps): React.ReactNode => { 178 180 const pal = usePalette('default') 179 181 const backgroundColor = pal.colors.backgroundLight ··· 224 226 uri: hackModifyThumbnailPath(avatar, size < 90), 225 227 }} 226 228 blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} 229 + onLoad={onLoad} 227 230 /> 228 231 ) : ( 229 232 <HighPriorityImage ··· 234 237 uri: hackModifyThumbnailPath(avatar, size < 90), 235 238 }} 236 239 blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} 240 + onLoad={onLoad} 237 241 /> 238 242 )} 239 243 {alert}