+1
src/lib/statsig/gates.ts
+1
src/lib/statsig/gates.ts
+4
src/logger/metrics.ts
+4
src/logger/metrics.ts
+1
src/state/shell/composer/index.tsx
+1
src/state/shell/composer/index.tsx
+5
src/view/com/composer/Composer.tsx
+5
src/view/com/composer/Composer.tsx
···
172
text: initText,
173
imageUris: initImageUris,
174
videoUri: initVideoUri,
175
cancelRef,
176
}: Props & {
177
cancelRef?: React.RefObject<CancelRef | null>
···
721
}}
722
currentLanguages={currentLanguages}
723
onSelectLanguage={onSelectLanguage}
724
/>
725
</>
726
)
···
1334
onAddPost,
1335
currentLanguages,
1336
onSelectLanguage,
1337
}: {
1338
post: PostDraft
1339
dispatch: (action: PostAction) => void
···
1344
onAddPost: () => void
1345
currentLanguages: string[]
1346
onSelectLanguage?: (language: string) => void
1347
}) {
1348
const t = useTheme()
1349
const {_} = useLingui()
···
1463
allowedAssetTypes={selectedAssetsType}
1464
selectedAssetsCount={selectedAssetsCount}
1465
onSelectAssets={onSelectAssets}
1466
/>
1467
<OpenCameraBtn
1468
disabled={media?.type === 'images' ? isMaxImages : !!media}
···
172
text: initText,
173
imageUris: initImageUris,
174
videoUri: initVideoUri,
175
+
openGallery,
176
cancelRef,
177
}: Props & {
178
cancelRef?: React.RefObject<CancelRef | null>
···
722
}}
723
currentLanguages={currentLanguages}
724
onSelectLanguage={onSelectLanguage}
725
+
openGallery={openGallery}
726
/>
727
</>
728
)
···
1336
onAddPost,
1337
currentLanguages,
1338
onSelectLanguage,
1339
+
openGallery,
1340
}: {
1341
post: PostDraft
1342
dispatch: (action: PostAction) => void
···
1347
onAddPost: () => void
1348
currentLanguages: string[]
1349
onSelectLanguage?: (language: string) => void
1350
+
openGallery?: boolean
1351
}) {
1352
const t = useTheme()
1353
const {_} = useLingui()
···
1467
allowedAssetTypes={selectedAssetsType}
1468
selectedAssetsCount={selectedAssetsCount}
1469
onSelectAssets={onSelectAssets}
1470
+
autoOpen={openGallery}
1471
/>
1472
<OpenCameraBtn
1473
disabled={media?.type === 'images' ? isMaxImages : !!media}
+14
-1
src/view/com/composer/SelectMediaButton.tsx
+14
-1
src/view/com/composer/SelectMediaButton.tsx
···
1
-
import {useCallback} from 'react'
2
import {Keyboard} from 'react-native'
3
import {type ImagePickerAsset} from 'expo-image-picker'
4
import {msg, plural} from '@lingui/macro'
···
31
assets: ImagePickerAsset[]
32
errors: string[]
33
}) => void
34
}
35
36
/**
···
358
allowedAssetTypes,
359
selectedAssetsCount,
360
onSelectAssets,
361
}: SelectMediaButtonProps) {
362
const {_} = useLingui()
363
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
364
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
365
const sheetWrapper = useSheetWrapper()
366
const t = useTheme()
367
368
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
369
···
459
processSelectedAssets,
460
selectionCountRemaining,
461
])
462
463
return (
464
<Button
···
1
+
import {useCallback, useEffect, useRef} from 'react'
2
import {Keyboard} from 'react-native'
3
import {type ImagePickerAsset} from 'expo-image-picker'
4
import {msg, plural} from '@lingui/macro'
···
31
assets: ImagePickerAsset[]
32
errors: string[]
33
}) => void
34
+
/**
35
+
* If true, automatically open the media picker when the component mounts.
36
+
*/
37
+
autoOpen?: boolean
38
}
39
40
/**
···
362
allowedAssetTypes,
363
selectedAssetsCount,
364
onSelectAssets,
365
+
autoOpen,
366
}: SelectMediaButtonProps) {
367
const {_} = useLingui()
368
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
369
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
370
const sheetWrapper = useSheetWrapper()
371
const t = useTheme()
372
+
const hasAutoOpened = useRef(false)
373
374
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
375
···
465
processSelectedAssets,
466
selectionCountRemaining,
467
])
468
+
469
+
useEffect(() => {
470
+
if (autoOpen && !hasAutoOpened.current && !disabled) {
471
+
hasAutoOpened.current = true
472
+
onPressSelectMedia()
473
+
}
474
+
}, [autoOpen, disabled, onPressSelectMedia])
475
476
return (
477
<Button
+247
src/view/com/feeds/ComposerPrompt.tsx
+247
src/view/com/feeds/ComposerPrompt.tsx
···
···
1
+
import React, {useCallback, useState} from 'react'
2
+
import {Keyboard, Pressable, View} from 'react-native'
3
+
import {msg} from '@lingui/macro'
4
+
import {useLingui} from '@lingui/react'
5
+
6
+
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
7
+
import {
8
+
useCameraPermission,
9
+
usePhotoLibraryPermission,
10
+
useVideoLibraryPermission,
11
+
} from '#/lib/hooks/usePermissions'
12
+
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
13
+
import {logger} from '#/logger'
14
+
import {isNative} from '#/platform/detection'
15
+
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
16
+
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
17
+
import {UserAvatar} from '#/view/com/util/UserAvatar'
18
+
import {atoms as a, native, useTheme, web} from '#/alf'
19
+
import {Button} from '#/components/Button'
20
+
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
21
+
import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera'
22
+
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
23
+
import {SubtleHover} from '#/components/SubtleHover'
24
+
import {Text} from '#/components/Typography'
25
+
26
+
export function ComposerPrompt() {
27
+
const {_} = useLingui()
28
+
const t = useTheme()
29
+
const {openComposer} = useOpenComposer()
30
+
const profile = useCurrentAccountProfile()
31
+
const [hover, setHover] = useState(false)
32
+
const {requestCameraAccessIfNeeded} = useCameraPermission()
33
+
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
34
+
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
35
+
const sheetWrapper = useSheetWrapper()
36
+
37
+
const onPress = React.useCallback(() => {
38
+
logger.metric('composerPrompt:press', {})
39
+
openComposer({})
40
+
}, [openComposer])
41
+
42
+
const onPressImage = useCallback(async () => {
43
+
logger.metric('composerPrompt:gallery:press', {})
44
+
45
+
// On web, open the composer with the gallery picker auto-opening
46
+
if (!isNative) {
47
+
openComposer({openGallery: true})
48
+
return
49
+
}
50
+
51
+
try {
52
+
const [photoAccess, videoAccess] = await Promise.all([
53
+
requestPhotoAccessIfNeeded(),
54
+
requestVideoAccessIfNeeded(),
55
+
])
56
+
57
+
if (!photoAccess && !videoAccess) {
58
+
return
59
+
}
60
+
61
+
if (Keyboard.isVisible()) {
62
+
Keyboard.dismiss()
63
+
}
64
+
65
+
const selectionCountRemaining = MAX_IMAGES
66
+
const {assets, canceled} = await sheetWrapper(
67
+
openUnifiedPicker({selectionCountRemaining}),
68
+
)
69
+
70
+
if (canceled) {
71
+
return
72
+
}
73
+
74
+
if (assets.length > 0) {
75
+
const imageUris = assets
76
+
.filter(asset => asset.mimeType?.startsWith('image/'))
77
+
.slice(0, MAX_IMAGES)
78
+
.map(asset => ({
79
+
uri: asset.uri,
80
+
width: asset.width,
81
+
height: asset.height,
82
+
}))
83
+
84
+
if (imageUris.length > 0) {
85
+
openComposer({imageUris})
86
+
}
87
+
}
88
+
} catch (err: any) {
89
+
if (!String(err).toLowerCase().includes('cancel')) {
90
+
logger.warn('Error opening image picker', {error: err})
91
+
}
92
+
}
93
+
}, [
94
+
openComposer,
95
+
requestPhotoAccessIfNeeded,
96
+
requestVideoAccessIfNeeded,
97
+
sheetWrapper,
98
+
])
99
+
100
+
const onPressCamera = useCallback(async () => {
101
+
logger.metric('composerPrompt:camera:press', {})
102
+
103
+
try {
104
+
if (!(await requestCameraAccessIfNeeded())) {
105
+
return
106
+
}
107
+
108
+
if (isNative && Keyboard.isVisible()) {
109
+
Keyboard.dismiss()
110
+
}
111
+
112
+
const image = await openCamera({
113
+
mediaTypes: 'images',
114
+
})
115
+
116
+
const imageUris = [
117
+
{
118
+
uri: image.path,
119
+
width: image.width,
120
+
height: image.height,
121
+
},
122
+
]
123
+
124
+
openComposer({
125
+
imageUris: isNative ? imageUris : undefined,
126
+
})
127
+
} catch (err: any) {
128
+
if (!String(err).toLowerCase().includes('cancel')) {
129
+
logger.warn('Error opening camera', {error: err})
130
+
}
131
+
}
132
+
}, [openComposer, requestCameraAccessIfNeeded])
133
+
134
+
if (!profile) {
135
+
return null
136
+
}
137
+
138
+
return (
139
+
<Pressable
140
+
onPress={onPress}
141
+
android_ripple={null}
142
+
accessibilityRole="button"
143
+
accessibilityLabel={_(msg`Compose new post`)}
144
+
accessibilityHint={_(msg`Opens the post composer`)}
145
+
onPointerEnter={() => setHover(true)}
146
+
onPointerLeave={() => setHover(false)}
147
+
style={({pressed}) => [
148
+
a.relative,
149
+
a.flex_row,
150
+
a.align_start,
151
+
a.border_t,
152
+
t.atoms.border_contrast_low,
153
+
{
154
+
paddingLeft: 18,
155
+
paddingRight: 15,
156
+
},
157
+
a.py_md,
158
+
native({
159
+
paddingTop: 10,
160
+
paddingBottom: 10,
161
+
}),
162
+
web({
163
+
cursor: 'pointer',
164
+
outline: 'none',
165
+
}),
166
+
pressed && web({outline: 'none'}),
167
+
]}>
168
+
<SubtleHover hover={hover} />
169
+
<UserAvatar
170
+
avatar={profile.avatar}
171
+
size={40}
172
+
type={profile.associated?.labeler ? 'labeler' : 'user'}
173
+
/>
174
+
<View style={[a.flex_1, a.ml_md, a.flex_row, a.align_center, a.gap_xs]}>
175
+
<View
176
+
style={[
177
+
a.flex_1,
178
+
a.flex_row,
179
+
a.align_center,
180
+
a.justify_between,
181
+
a.px_md,
182
+
a.rounded_full,
183
+
t.atoms.bg_contrast_50,
184
+
{
185
+
height: 40,
186
+
},
187
+
]}>
188
+
<Text
189
+
style={[
190
+
t.atoms.text_contrast_low,
191
+
a.text_md,
192
+
a.pl_xs,
193
+
{
194
+
includeFontPadding: false,
195
+
},
196
+
]}>
197
+
{_(msg`What's up?`)}
198
+
</Text>
199
+
<View style={[a.flex_row, a.gap_md, a.mr_xs]}>
200
+
{isNative && (
201
+
<Button
202
+
onPress={e => {
203
+
e.stopPropagation()
204
+
onPressCamera()
205
+
}}
206
+
label={_(msg`Open camera`)}
207
+
accessibilityHint={_(msg`Opens device camera`)}
208
+
variant="ghost"
209
+
shape="round">
210
+
{({hovered}) => (
211
+
<CameraIcon
212
+
size="md"
213
+
style={{
214
+
color: hovered
215
+
? t.palette.primary_500
216
+
: t.palette.contrast_300,
217
+
}}
218
+
/>
219
+
)}
220
+
</Button>
221
+
)}
222
+
<Button
223
+
onPress={e => {
224
+
e.stopPropagation()
225
+
onPressImage()
226
+
}}
227
+
label={_(msg`Add image`)}
228
+
accessibilityHint={_(msg`Opens image picker`)}
229
+
variant="ghost"
230
+
shape="round">
231
+
{({hovered}) => (
232
+
<ImageIcon
233
+
size="md"
234
+
style={{
235
+
color: hovered
236
+
? t.palette.primary_500
237
+
: t.palette.contrast_300,
238
+
}}
239
+
/>
240
+
)}
241
+
</Button>
242
+
</View>
243
+
</View>
244
+
</View>
245
+
</Pressable>
246
+
)
247
+
}
+33
-1
src/view/com/posts/PostFeed.tsx
+33
-1
src/view/com/posts/PostFeed.tsx
···
31
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
32
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
33
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
34
-
import {logEvent} from '#/lib/statsig/statsig'
35
import {isNetworkError} from '#/lib/strings/errors'
36
import {logger} from '#/logger'
37
import {isIOS, isNative, isWeb} from '#/platform/detection'
···
70
} from '#/components/feeds/PostFeedVideoGridRow'
71
import {TrendingInterstitial} from '#/components/interstitials/Trending'
72
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
73
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
74
import {FeedShutdownMsg} from './FeedShutdownMsg'
75
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
···
150
type: 'ageAssuranceBanner'
151
key: string
152
}
153
154
export function getItemsForFeedback(feedRow: FeedRow): {
155
item: FeedPostSliceItem
···
225
const {_} = useLingui()
226
const queryClient = useQueryClient()
227
const {currentAccount, hasSession} = useSession()
228
const initialNumToRender = useInitialNumToRender()
229
const feedFeedback = useFeedFeedbackContext()
230
const [isPTRing, setIsPTRing] = useState(false)
···
511
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
512
})
513
}
514
} else if (sliceIndex === 15) {
515
if (areVideoFeedsEnabled && !trendingVideoDisabled) {
516
arr.push({
···
524
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
525
})
526
}
527
} else if (feedKind === 'profile') {
528
if (sliceIndex === 5) {
529
arr.push({
···
638
isEmpty,
639
lastFetchedAt,
640
data,
641
feedType,
642
feedUriOrActorDid,
643
feedTab,
···
652
hasPressedShowLessUris,
653
ageAssuranceBannerState,
654
isCurrentFeedAtStartupSelected,
655
blockedOrMutedAuthors,
656
])
657
···
743
return <AgeAssuranceDismissibleFeedBanner />
744
} else if (row.type === 'interstitialTrending') {
745
return <TrendingInterstitial />
746
} else if (row.type === 'interstitialTrendingVideos') {
747
return <TrendingVideosInterstitial />
748
} else if (row.type === 'fallbackMarker') {
···
31
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
32
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
33
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
34
+
import {logEvent, useGate} from '#/lib/statsig/statsig'
35
import {isNetworkError} from '#/lib/strings/errors'
36
import {logger} from '#/logger'
37
import {isIOS, isNative, isWeb} from '#/platform/detection'
···
70
} from '#/components/feeds/PostFeedVideoGridRow'
71
import {TrendingInterstitial} from '#/components/interstitials/Trending'
72
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
73
+
import {ComposerPrompt} from '../feeds/ComposerPrompt'
74
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
75
import {FeedShutdownMsg} from './FeedShutdownMsg'
76
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
···
151
type: 'ageAssuranceBanner'
152
key: string
153
}
154
+
| {
155
+
type: 'composerPrompt'
156
+
key: string
157
+
}
158
159
export function getItemsForFeedback(feedRow: FeedRow): {
160
item: FeedPostSliceItem
···
230
const {_} = useLingui()
231
const queryClient = useQueryClient()
232
const {currentAccount, hasSession} = useSession()
233
+
const gate = useGate()
234
const initialNumToRender = useInitialNumToRender()
235
const feedFeedback = useFeedFeedbackContext()
236
const [isPTRing, setIsPTRing] = useState(false)
···
517
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
518
})
519
}
520
+
// Show composer prompt for Discover and Following feeds
521
+
if (
522
+
hasSession &&
523
+
(feedUriOrActorDid === DISCOVER_FEED_URI ||
524
+
feed === 'following') &&
525
+
gate('show_composer_prompt')
526
+
) {
527
+
arr.push({
528
+
type: 'composerPrompt',
529
+
key: 'composerPrompt-' + sliceIndex,
530
+
})
531
+
}
532
} else if (sliceIndex === 15) {
533
if (areVideoFeedsEnabled && !trendingVideoDisabled) {
534
arr.push({
···
542
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
543
})
544
}
545
+
} else if (feedKind === 'following') {
546
+
if (sliceIndex === 0) {
547
+
// Show composer prompt for Following feed
548
+
if (hasSession && gate('show_composer_prompt')) {
549
+
arr.push({
550
+
type: 'composerPrompt',
551
+
key: 'composerPrompt-' + sliceIndex,
552
+
})
553
+
}
554
+
}
555
} else if (feedKind === 'profile') {
556
if (sliceIndex === 5) {
557
arr.push({
···
666
isEmpty,
667
lastFetchedAt,
668
data,
669
+
feed,
670
feedType,
671
feedUriOrActorDid,
672
feedTab,
···
681
hasPressedShowLessUris,
682
ageAssuranceBannerState,
683
isCurrentFeedAtStartupSelected,
684
+
gate,
685
blockedOrMutedAuthors,
686
])
687
···
773
return <AgeAssuranceDismissibleFeedBanner />
774
} else if (row.type === 'interstitialTrending') {
775
return <TrendingInterstitial />
776
+
} else if (row.type === 'composerPrompt') {
777
+
return <ComposerPrompt />
778
} else if (row.type === 'interstitialTrendingVideos') {
779
return <TrendingVideosInterstitial />
780
} else if (row.type === 'fallbackMarker') {
+1
src/view/shell/Composer.ios.tsx
+1
src/view/shell/Composer.ios.tsx
+1
src/view/shell/Composer.tsx
+1
src/view/shell/Composer.tsx
+1
src/view/shell/Composer.web.tsx
+1
src/view/shell/Composer.web.tsx