mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at session-alignment 602 lines 18 kB view raw
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})