mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2import {
3 ActivityIndicator,
4 BackHandler,
5 Keyboard,
6 KeyboardAvoidingView,
7 Platform,
8 Pressable,
9 ScrollView,
10 StyleSheet,
11 TouchableOpacity,
12 View,
13} from 'react-native'
14import {useSafeAreaInsets} from 'react-native-safe-area-context'
15import {LinearGradient} from 'expo-linear-gradient'
16import {RichText} from '@atproto/api'
17import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
18import {msg, Trans} from '@lingui/macro'
19import {useLingui} from '@lingui/react'
20import {observer} from 'mobx-react-lite'
21
22import {logEvent} from '#/lib/statsig/statsig'
23import {logger} from '#/logger'
24import {emitPostCreated} from '#/state/events'
25import {useModals} from '#/state/modals'
26import {useRequireAltTextEnabled} from '#/state/preferences'
27import {
28 toPostLanguages,
29 useLanguagePrefs,
30 useLanguagePrefsApi,
31} from '#/state/preferences/languages'
32import {useProfileQuery} from '#/state/queries/profile'
33import {ThreadgateSetting} from '#/state/queries/threadgate'
34import {getAgent, useSession} from '#/state/session'
35import {useComposerControls} from '#/state/shell/composer'
36import {useAnalytics} from 'lib/analytics/analytics'
37import * as apilib from 'lib/api/index'
38import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
39import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
40import {usePalette} from 'lib/hooks/usePalette'
41import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
42import {cleanError} from 'lib/strings/errors'
43import {insertMentionAt} from 'lib/strings/mention-manip'
44import {shortenLinks} from 'lib/strings/rich-text-manip'
45import {toShortUrl} from 'lib/strings/url-helpers'
46import {colors, gradients, s} from 'lib/styles'
47import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
48import {useDialogStateControlContext} from 'state/dialogs'
49import {GalleryModel} from 'state/models/media/gallery'
50import {ComposerOpts} from 'state/shell/composer'
51import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
52import * as Prompt from '#/components/Prompt'
53import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
54import {Text} from '../util/text/Text'
55import * as Toast from '../util/Toast'
56import {UserAvatar} from '../util/UserAvatar'
57import {CharProgress} from './char-progress/CharProgress'
58import {ExternalEmbed} from './ExternalEmbed'
59import {LabelsBtn} from './labels/LabelsBtn'
60import {Gallery} from './photos/Gallery'
61import {OpenCameraBtn} from './photos/OpenCameraBtn'
62import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
63import {SelectLangBtn} from './select-language/SelectLangBtn'
64import {SuggestedLanguage} from './select-language/SuggestedLanguage'
65// TODO: Prevent naming components that coincide with RN primitives
66// due to linting false positives
67import {TextInput, TextInputRef} from './text-input/TextInput'
68import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
69import {useExternalLinkFetch} from './useExternalLinkFetch'
70
71type Props = ComposerOpts
72export const ComposePost = observer(function ComposePost({
73 replyTo,
74 onPost,
75 quote: initQuote,
76 mention: initMention,
77 openPicker,
78 text: initText,
79 imageUris: initImageUris,
80}: Props) {
81 const {currentAccount} = useSession()
82 const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
83 const {isModalActive} = useModals()
84 const {closeComposer} = useComposerControls()
85 const {track} = useAnalytics()
86 const pal = usePalette('default')
87 const {isDesktop, isMobile} = useWebMediaQueries()
88 const {_} = useLingui()
89 const requireAltTextEnabled = useRequireAltTextEnabled()
90 const langPrefs = useLanguagePrefs()
91 const setLangPrefs = useLanguagePrefsApi()
92 const textInput = useRef<TextInputRef>(null)
93 const discardPromptControl = Prompt.usePromptControl()
94 const {closeAllDialogs} = useDialogStateControlContext()
95
96 const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
97 const [isProcessing, setIsProcessing] = useState(false)
98 const [processingState, setProcessingState] = useState('')
99 const [error, setError] = useState('')
100 const [richtext, setRichText] = useState(
101 new RichText({
102 text: initText
103 ? initText
104 : initMention
105 ? insertMentionAt(
106 `@${initMention}`,
107 initMention.length + 1,
108 `${initMention}`,
109 ) // insert mention if passed in
110 : '',
111 }),
112 )
113 const graphemeLength = useMemo(() => {
114 return shortenLinks(richtext).graphemeLength
115 }, [richtext])
116 const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
117 initQuote,
118 )
119 const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
120 const [labels, setLabels] = useState<string[]>([])
121 const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
122 const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
123 const gallery = useMemo(
124 () => new GalleryModel(initImageUris),
125 [initImageUris],
126 )
127 const onClose = useCallback(() => {
128 closeComposer()
129 }, [closeComposer])
130
131 const insets = useSafeAreaInsets()
132 const viewStyles = useMemo(
133 () => ({
134 paddingBottom:
135 isAndroid || (isIOS && !isKeyboardVisible) ? insets.bottom : 0,
136 paddingTop: isAndroid ? insets.top : isMobile ? 15 : 0,
137 }),
138 [insets, isKeyboardVisible, isMobile],
139 )
140
141 const onPressCancel = useCallback(() => {
142 if (graphemeLength > 0 || !gallery.isEmpty) {
143 closeAllDialogs()
144 if (Keyboard) {
145 Keyboard.dismiss()
146 }
147 discardPromptControl.open()
148 } else {
149 onClose()
150 }
151 }, [
152 graphemeLength,
153 gallery.isEmpty,
154 closeAllDialogs,
155 discardPromptControl,
156 onClose,
157 ])
158 // android back button
159 useEffect(() => {
160 if (!isAndroid) {
161 return
162 }
163 const backHandler = BackHandler.addEventListener(
164 'hardwareBackPress',
165 () => {
166 onPressCancel()
167 return true
168 },
169 )
170
171 return () => {
172 backHandler.remove()
173 }
174 }, [onPressCancel])
175
176 // listen to escape key on desktop web
177 const onEscape = useCallback(
178 (e: KeyboardEvent) => {
179 if (e.key === 'Escape') {
180 onPressCancel()
181 }
182 },
183 [onPressCancel],
184 )
185 useEffect(() => {
186 if (isWeb && !isModalActive) {
187 window.addEventListener('keydown', onEscape)
188 return () => window.removeEventListener('keydown', onEscape)
189 }
190 }, [onEscape, isModalActive])
191
192 const onPressAddLinkCard = useCallback(
193 (uri: string) => {
194 setExtLink({uri, isLoading: true})
195 },
196 [setExtLink],
197 )
198
199 const onPhotoPasted = useCallback(
200 async (uri: string) => {
201 track('Composer:PastedPhotos')
202 await gallery.paste(uri)
203 },
204 [gallery, track],
205 )
206
207 const onPressPublish = async () => {
208 if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
209 return
210 }
211 if (requireAltTextEnabled && gallery.needsAltText) {
212 return
213 }
214
215 setError('')
216
217 if (richtext.text.trim().length === 0 && gallery.isEmpty && !extLink) {
218 setError(_(msg`Did you want to say anything?`))
219 return
220 }
221 if (extLink?.isLoading) {
222 setError(_(msg`Please wait for your link card to finish loading`))
223 return
224 }
225
226 setIsProcessing(true)
227
228 let postUri
229 try {
230 postUri = (
231 await apilib.post(getAgent(), {
232 rawText: richtext.text,
233 replyTo: replyTo?.uri,
234 images: gallery.images,
235 quote,
236 extLink,
237 labels,
238 threadgate,
239 onStateChange: setProcessingState,
240 langs: toPostLanguages(langPrefs.postLanguage),
241 })
242 ).uri
243 } catch (e: any) {
244 logger.error(e, {
245 message: `Composer: create post failed`,
246 hasImages: gallery.size > 0,
247 })
248
249 if (extLink) {
250 setExtLink({
251 ...extLink,
252 isLoading: true,
253 localThumb: undefined,
254 } as apilib.ExternalEmbedDraft)
255 }
256 setError(cleanError(e.message))
257 setIsProcessing(false)
258 return
259 } finally {
260 if (postUri) {
261 logEvent('post:create', {
262 imageCount: gallery.size,
263 isReply: replyTo != null,
264 hasLink: extLink != null,
265 hasQuote: quote != null,
266 langs: langPrefs.postLanguage,
267 logContext: 'Composer',
268 })
269 }
270 track('Create Post', {
271 imageCount: gallery.size,
272 })
273 if (replyTo && replyTo.uri) track('Post:Reply')
274 }
275 if (postUri && !replyTo) {
276 emitPostCreated()
277 }
278 setLangPrefs.savePostLanguageToHistory()
279 onPost?.()
280 onClose()
281 Toast.show(
282 replyTo
283 ? _(msg`Your reply has been published`)
284 : _(msg`Your post has been published`),
285 )
286 }
287
288 const canPost = useMemo(
289 () =>
290 graphemeLength <= MAX_GRAPHEME_LENGTH &&
291 (!requireAltTextEnabled || !gallery.needsAltText),
292 [graphemeLength, requireAltTextEnabled, gallery.needsAltText],
293 )
294 const selectTextInputPlaceholder = replyTo
295 ? _(msg`Write your reply`)
296 : _(msg`What's up?`)
297
298 const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
299 const hasMedia = gallery.size > 0 || Boolean(extLink)
300
301 const onEmojiButtonPress = useCallback(() => {
302 openPicker?.(textInput.current?.getCursorPosition())
303 }, [openPicker])
304
305 return (
306 <KeyboardAvoidingView
307 testID="composePostView"
308 behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
309 style={styles.outer}>
310 <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
311 <View style={[styles.topbar, isDesktop && styles.topbarDesktop]}>
312 <TouchableOpacity
313 testID="composerDiscardButton"
314 onPress={onPressCancel}
315 onAccessibilityEscape={onPressCancel}
316 accessibilityRole="button"
317 accessibilityLabel={_(msg`Cancel`)}
318 accessibilityHint={_(
319 msg`Closes post composer and discards post draft`,
320 )}>
321 <Text style={[pal.link, s.f18]}>
322 <Trans>Cancel</Trans>
323 </Text>
324 </TouchableOpacity>
325 <View style={s.flex1} />
326 {isProcessing ? (
327 <>
328 <Text style={pal.textLight}>{processingState}</Text>
329 <View style={styles.postBtn}>
330 <ActivityIndicator />
331 </View>
332 </>
333 ) : (
334 <>
335 <LabelsBtn
336 labels={labels}
337 onChange={setLabels}
338 hasMedia={hasMedia}
339 />
340 {replyTo ? null : (
341 <ThreadgateBtn
342 threadgate={threadgate}
343 onChange={setThreadgate}
344 />
345 )}
346 {canPost ? (
347 <TouchableOpacity
348 testID="composerPublishBtn"
349 onPress={onPressPublish}
350 accessibilityRole="button"
351 accessibilityLabel={
352 replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
353 }
354 accessibilityHint="">
355 <LinearGradient
356 colors={[
357 gradients.blueLight.start,
358 gradients.blueLight.end,
359 ]}
360 start={{x: 0, y: 0}}
361 end={{x: 1, y: 1}}
362 style={styles.postBtn}>
363 <Text style={[s.white, s.f16, s.bold]}>
364 {replyTo ? (
365 <Trans context="action">Reply</Trans>
366 ) : (
367 <Trans context="action">Post</Trans>
368 )}
369 </Text>
370 </LinearGradient>
371 </TouchableOpacity>
372 ) : (
373 <View style={[styles.postBtn, pal.btn]}>
374 <Text style={[pal.textLight, s.f16, s.bold]}>
375 <Trans context="action">Post</Trans>
376 </Text>
377 </View>
378 )}
379 </>
380 )}
381 </View>
382 {requireAltTextEnabled && gallery.needsAltText && (
383 <View style={[styles.reminderLine, pal.viewLight]}>
384 <View style={styles.errorIcon}>
385 <FontAwesomeIcon
386 icon="exclamation"
387 style={{color: colors.red4}}
388 size={10}
389 />
390 </View>
391 <Text style={[pal.text, s.flex1]}>
392 <Trans>One or more images is missing alt text.</Trans>
393 </Text>
394 </View>
395 )}
396 {error !== '' && (
397 <View style={styles.errorLine}>
398 <View style={styles.errorIcon}>
399 <FontAwesomeIcon
400 icon="exclamation"
401 style={{color: colors.red4}}
402 size={10}
403 />
404 </View>
405 <Text style={[s.red4, s.flex1]}>{error}</Text>
406 </View>
407 )}
408 <ScrollView
409 style={styles.scrollView}
410 keyboardShouldPersistTaps="always">
411 {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
412
413 <View
414 style={[
415 pal.border,
416 styles.textInputLayout,
417 isNative && styles.textInputLayoutMobile,
418 ]}>
419 <UserAvatar
420 avatar={currentProfile?.avatar}
421 size={50}
422 type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
423 />
424 <TextInput
425 ref={textInput}
426 richtext={richtext}
427 placeholder={selectTextInputPlaceholder}
428 suggestedLinks={suggestedLinks}
429 autoFocus={true}
430 setRichText={setRichText}
431 onPhotoPasted={onPhotoPasted}
432 onPressPublish={onPressPublish}
433 onSuggestedLinksChanged={setSuggestedLinks}
434 onError={setError}
435 accessible={true}
436 accessibilityLabel={_(msg`Write post`)}
437 accessibilityHint={_(
438 msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
439 )}
440 />
441 </View>
442
443 <Gallery gallery={gallery} />
444 {gallery.isEmpty && extLink && (
445 <ExternalEmbed
446 link={extLink}
447 onRemove={() => setExtLink(undefined)}
448 />
449 )}
450 {quote ? (
451 <View style={[s.mt5, isWeb && s.mb10, {pointerEvents: 'none'}]}>
452 <QuoteEmbed quote={quote} />
453 </View>
454 ) : undefined}
455 </ScrollView>
456 {!extLink && suggestedLinks.size > 0 ? (
457 <View style={s.mb5}>
458 {Array.from(suggestedLinks)
459 .slice(0, 3)
460 .map(url => (
461 <TouchableOpacity
462 key={`suggested-${url}`}
463 testID="addLinkCardBtn"
464 style={[pal.borderDark, styles.addExtLinkBtn]}
465 onPress={() => onPressAddLinkCard(url)}
466 accessibilityRole="button"
467 accessibilityLabel={_(msg`Add link card`)}
468 accessibilityHint={_(
469 msg`Creates a card with a thumbnail. The card links to ${url}`,
470 )}>
471 <Text style={pal.text}>
472 <Trans>Add link card:</Trans>{' '}
473 <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text>
474 </Text>
475 </TouchableOpacity>
476 ))}
477 </View>
478 ) : null}
479 <SuggestedLanguage text={richtext.text} />
480 <View style={[pal.border, styles.bottomBar]}>
481 {canSelectImages ? (
482 <>
483 <SelectPhotoBtn gallery={gallery} />
484 <OpenCameraBtn gallery={gallery} />
485 </>
486 ) : null}
487 {!isMobile ? (
488 <Pressable
489 onPress={onEmojiButtonPress}
490 accessibilityRole="button"
491 accessibilityLabel={_(msg`Open emoji picker`)}
492 accessibilityHint={_(msg`Open emoji picker`)}>
493 <FontAwesomeIcon
494 icon={['far', 'face-smile']}
495 color={pal.colors.link}
496 size={22}
497 />
498 </Pressable>
499 ) : null}
500 <View style={s.flex1} />
501 <SelectLangBtn />
502 <CharProgress count={graphemeLength} />
503 </View>
504 </View>
505
506 <Prompt.Basic
507 control={discardPromptControl}
508 title={_(msg`Discard draft?`)}
509 description={_(msg`Are you sure you'd like to discard this draft?`)}
510 onConfirm={() => {
511 discardPromptControl.close(onClose)
512 }}
513 confirmButtonCta={_(msg`Discard`)}
514 confirmButtonColor="negative"
515 />
516 </KeyboardAvoidingView>
517 )
518})
519
520const styles = StyleSheet.create({
521 outer: {
522 flexDirection: 'column',
523 flex: 1,
524 height: '100%',
525 },
526 topbar: {
527 flexDirection: 'row',
528 alignItems: 'center',
529 paddingTop: 6,
530 paddingBottom: 4,
531 paddingHorizontal: 20,
532 height: 55,
533 gap: 4,
534 },
535 topbarDesktop: {
536 paddingTop: 10,
537 paddingBottom: 10,
538 },
539 postBtn: {
540 borderRadius: 20,
541 paddingHorizontal: 20,
542 paddingVertical: 6,
543 marginLeft: 12,
544 },
545 errorLine: {
546 flexDirection: 'row',
547 backgroundColor: colors.red1,
548 borderRadius: 6,
549 marginHorizontal: 15,
550 paddingHorizontal: 8,
551 paddingVertical: 6,
552 marginVertical: 6,
553 },
554 reminderLine: {
555 flexDirection: 'row',
556 alignItems: 'center',
557 borderRadius: 6,
558 marginHorizontal: 15,
559 paddingHorizontal: 8,
560 paddingVertical: 6,
561 marginBottom: 6,
562 },
563 errorIcon: {
564 borderWidth: 1,
565 borderColor: colors.red4,
566 color: colors.red4,
567 borderRadius: 30,
568 width: 16,
569 height: 16,
570 alignItems: 'center',
571 justifyContent: 'center',
572 marginRight: 5,
573 },
574 scrollView: {
575 flex: 1,
576 paddingHorizontal: 15,
577 },
578 textInputLayout: {
579 flexDirection: 'row',
580 borderTopWidth: 1,
581 paddingTop: 16,
582 },
583 textInputLayoutMobile: {
584 flex: 1,
585 },
586 addExtLinkBtn: {
587 borderWidth: 1,
588 borderRadius: 24,
589 paddingHorizontal: 16,
590 paddingVertical: 12,
591 marginHorizontal: 10,
592 marginBottom: 4,
593 },
594 bottomBar: {
595 flexDirection: 'row',
596 paddingVertical: 10,
597 paddingLeft: 15,
598 paddingRight: 20,
599 alignItems: 'center',
600 borderTopWidth: 1,
601 },
602})