tangled
alpha
login
or
join now
ansxor.ca
/
witchsky.app
forked from
jollywhoppers.com/witchsky.app
1
fork
atom
Bluesky app fork with some witchin' additions 💫
1
fork
atom
overview
issues
pulls
pipelines
WIP, avi not working on web
Eric Bailey
2 years ago
eaf00816
3c8b3b47
+266
-176
3 changed files
expand all
collapse all
unified
split
src
components
dialogs
nudges
TenMillion.tsx
lib
canvas.ts
view
com
util
UserAvatar.tsx
+247
-176
src/components/dialogs/nudges/TenMillion.tsx
···
1
import React from 'react'
2
import {View} from 'react-native'
3
import ViewShot from 'react-native-view-shot'
0
4
import {moderateProfile} from '@atproto/api'
5
import {msg, Trans} from '@lingui/macro'
6
import {useLingui} from '@lingui/react'
7
0
8
import {sanitizeDisplayName} from '#/lib/strings/display-names'
9
import {sanitizeHandle} from '#/lib/strings/handles'
10
import {isNative} from '#/platform/detection'
···
32
import {Loader} from '#/components/Loader'
33
import {Text} from '#/components/Typography'
34
0
35
const RATIO = 8 / 10
36
const WIDTH = 2000
37
const HEIGHT = WIDTH * RATIO
···
47
}
48
}
49
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
50
export function TenMillion() {
51
const t = useTheme()
52
const lightTheme = useTheme('light')
···
54
const {controls} = useContext()
55
const {gtMobile} = useBreakpoints()
56
const {openComposer} = useComposerControls()
57
-
const imageRef = React.useRef<ViewShot>(null)
58
const {currentAccount} = useSession()
59
const {isLoading: isProfileLoading, data: profile} = useProfileQuery({
60
did: currentAccount!.did,
···
65
? moderateProfile(profile, moderationOpts)
66
: undefined
67
}, [profile, moderationOpts])
0
68
69
-
const isLoading = isProfileLoading || !moderation || !profile
0
70
71
-
const userNumber = 56738
0
0
0
72
73
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
-
})
90
})
91
}
92
}
93
94
-
return (
95
-
<Dialog.Outer control={controls.tenMillion}>
96
-
<Dialog.Handle />
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
97
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,
113
},
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]}>
0
0
0
0
0
0
0
0
0
0
129
<View
130
style={[
131
-
a.absolute,
132
-
a.inset_0,
133
a.align_center,
134
a.justify_center,
0
135
{
136
-
top: -1,
137
-
bottom: -1,
138
-
left: -1,
139
-
right: -1,
140
-
paddingVertical: 32,
141
-
paddingHorizontal: 48,
142
},
143
]}>
144
-
<GradientFill gradient={tokens.gradients.bonfire} />
0
0
0
0
0
0
0
0
0
0
0
145
146
-
{isLoading ? (
147
-
<Loader size="xl" fill="white" />
148
-
) : (
149
-
<View
0
0
0
0
150
style={[
151
-
a.flex_1,
152
-
a.w_full,
153
-
a.align_center,
154
-
a.justify_center,
155
-
a.rounded_md,
0
0
0
0
0
0
0
0
0
0
156
{
157
-
backgroundColor: 'white',
158
-
shadowRadius: 32,
159
-
shadowOpacity: 0.1,
160
-
elevation: 24,
161
-
shadowColor: tokens.gradients.bonfire.values[0][1],
162
},
163
]}>
164
-
<View
165
style={[
166
a.absolute,
167
-
a.px_xl,
168
-
a.py_xl,
169
{
170
-
top: 0,
171
-
left: 0,
0
0
172
},
173
]}>
174
-
<Logomark fill={t.palette.primary_500} width={36} />
175
-
</View>
0
0
0
0
176
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
-
🎉
0
0
0
0
0
0
196
</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
-
]}>
208
<Text
209
style={[
210
-
a.absolute,
211
-
{
212
-
color: t.palette.primary_500,
213
-
fontSize: 32,
214
-
left: -18,
215
-
top: 8,
216
-
},
217
]}>
218
-
#
219
</Text>
220
-
{i18n.number(userNumber)}
221
-
</Text>
222
-
</View>
223
-
{/* End centered content */}
224
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
-
)}
249
</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>
274
</View>
275
</View>
276
</View>
277
-
)}
278
</View>
279
-
</ViewShot>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
280
</View>
281
-
</ThemeProvider>
0
0
282
283
<View style={[gtMobile ? a.p_2xl : a.p_xl]}>
284
<Text
···
321
variant="solid"
322
color="secondary"
323
shape="square"
324
-
onPress={share}>
325
<ButtonIcon icon={Share} />
326
</Button>
327
<Button
···
1
import React from 'react'
2
import {View} from 'react-native'
3
import ViewShot from 'react-native-view-shot'
4
+
import {Image} from 'expo-image'
5
import {moderateProfile} from '@atproto/api'
6
import {msg, Trans} from '@lingui/macro'
7
import {useLingui} from '@lingui/react'
8
9
+
import {getCanvas} from '#/lib/canvas'
10
import {sanitizeDisplayName} from '#/lib/strings/display-names'
11
import {sanitizeHandle} from '#/lib/strings/handles'
12
import {isNative} from '#/platform/detection'
···
34
import {Loader} from '#/components/Loader'
35
import {Text} from '#/components/Typography'
36
37
+
const DEBUG = false
38
const RATIO = 8 / 10
39
const WIDTH = 2000
40
const HEIGHT = WIDTH * RATIO
···
50
}
51
}
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
+
69
export function TenMillion() {
70
const t = useTheme()
71
const lightTheme = useTheme('light')
···
73
const {controls} = useContext()
74
const {gtMobile} = useBreakpoints()
75
const {openComposer} = useComposerControls()
0
76
const {currentAccount} = useSession()
77
const {isLoading: isProfileLoading, data: profile} = useProfileQuery({
78
did: currentAccount!.did,
···
83
? moderateProfile(profile, moderationOpts)
84
: undefined
85
}, [profile, moderationOpts])
86
+
const [uri, setUri] = React.useState<string | null>(null)
87
88
+
const isLoadingData = isProfileLoading || !moderation || !profile
89
+
const isLoadingImage = !uri
90
91
+
const userNumber = 56738 // TODO
92
+
93
+
const captureInProgress = React.useRef(false)
94
+
const imageRef = React.useRef<ViewShot>(null)
95
96
const share = () => {
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)
0
0
111
})
112
}
113
}
114
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
+
}
139
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,
0
0
153
},
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
+
179
<View
180
style={[
181
+
a.flex_1,
182
+
a.w_full,
183
a.align_center,
184
a.justify_center,
185
+
a.rounded_md,
186
{
187
+
backgroundColor: 'white',
188
+
shadowRadius: 32,
189
+
shadowOpacity: 0.1,
190
+
elevation: 24,
191
+
shadowColor: tokens.gradients.bonfire.values[0][1],
0
192
},
193
]}>
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>
206
207
+
{/* Centered content */}
208
+
<View
209
+
style={[
210
+
{
211
+
paddingBottom: 48,
212
+
},
213
+
]}>
214
+
<Text
215
style={[
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,
231
{
232
+
fontStyle: 'italic',
233
+
fontSize: getFontSize(userNumber),
234
+
fontWeight: '900',
235
+
letterSpacing: -2,
0
236
},
237
]}>
238
+
<Text
239
style={[
240
a.absolute,
0
0
241
{
242
+
color: t.palette.primary_500,
243
+
fontSize: 32,
244
+
left: -18,
245
+
top: 8,
246
},
247
]}>
248
+
#
249
+
</Text>
250
+
{i18n.number(userNumber)}
251
+
</Text>
252
+
</View>
253
+
{/* End centered content */}
254
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
+
)}
280
</Text>
281
+
<View style={[a.flex_row, a.justify_between]}>
0
0
0
0
0
0
0
0
0
0
282
<Text
283
style={[
284
+
a.text_sm,
285
+
a.font_semibold,
286
+
lightTheme.atoms.text_contrast_medium,
0
0
0
0
287
]}>
288
+
{sanitizeHandle(profile.handle, '@')}
289
</Text>
0
0
0
0
290
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
+
})}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
301
</Text>
302
+
)}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
303
</View>
304
</View>
305
</View>
306
+
</View>
307
</View>
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
+
)}
349
</View>
350
+
</Frame>
351
+
352
+
{canvas}
353
354
<View style={[gtMobile ? a.p_2xl : a.p_xl]}>
355
<Text
···
392
variant="solid"
393
color="secondary"
394
shape="square"
395
+
onPress={download}>
396
<ButtonIcon icon={Share} />
397
</Button>
398
<Button
+15
src/lib/canvas.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
interface UserAvatarProps extends BaseUserAvatarProps {
44
moderation?: ModerationUI
45
usePlainRNImage?: boolean
0
46
}
47
48
interface EditableUserAvatarProps extends BaseUserAvatarProps {
···
174
avatar,
175
moderation,
176
usePlainRNImage = false,
0
177
}: UserAvatarProps): React.ReactNode => {
178
const pal = usePalette('default')
179
const backgroundColor = pal.colors.backgroundLight
···
224
uri: hackModifyThumbnailPath(avatar, size < 90),
225
}}
226
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
0
227
/>
228
) : (
229
<HighPriorityImage
···
234
uri: hackModifyThumbnailPath(avatar, size < 90),
235
}}
236
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
0
237
/>
238
)}
239
{alert}
···
43
interface UserAvatarProps extends BaseUserAvatarProps {
44
moderation?: ModerationUI
45
usePlainRNImage?: boolean
46
+
onLoad?: () => void
47
}
48
49
interface EditableUserAvatarProps extends BaseUserAvatarProps {
···
175
avatar,
176
moderation,
177
usePlainRNImage = false,
178
+
onLoad,
179
}: UserAvatarProps): React.ReactNode => {
180
const pal = usePalette('default')
181
const backgroundColor = pal.colors.backgroundLight
···
226
uri: hackModifyThumbnailPath(avatar, size < 90),
227
}}
228
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
229
+
onLoad={onLoad}
230
/>
231
) : (
232
<HighPriorityImage
···
237
uri: hackModifyThumbnailPath(avatar, size < 90),
238
}}
239
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
240
+
onLoad={onLoad}
241
/>
242
)}
243
{alert}