diff options
Diffstat (limited to 'src/view')
33 files changed, 923 insertions, 3786 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index d0dbdfaba..20f2549ad 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -46,12 +46,14 @@ import { AppBskyFeedDefs, type AppBskyFeedGetPostThread, AppBskyUnspeccedDefs, + AtUri, type BskyAgent, type RichText, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' @@ -70,6 +72,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {mimeToExt} from '#/lib/media/video/util' +import {type NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {colors} from '#/lib/styles' @@ -107,14 +110,11 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' import {Gallery} from '#/view/com/composer/photos/Gallery' import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn' import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn' -import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn' +import {SelectPostLanguagesBtn} from '#/view/com/composer/select-language/SelectPostLanguagesDialog' import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage' // TODO: Prevent naming components that coincide with RN primitives // due to linting false positives -import { - TextInput, - type TextInputRef, -} from '#/view/com/composer/text-input/TextInput' +import {TextInput} from '#/view/com/composer/text-input/TextInput' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' @@ -128,7 +128,7 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' import * as Prompt from '#/components/Prompt' -import * as toast from '#/components/Toast' +import * as Toast from '#/components/Toast' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { @@ -152,6 +152,7 @@ import { processVideo, type VideoState, } from './state/video' +import {type TextInputRef} from './text-input/TextInput.types' import {getVideoMetadata} from './videos/pickVideo' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' @@ -188,6 +189,7 @@ export const ComposePost = ({ const {closeAllDialogs} = useDialogStateControlContext() const {closeAllModals} = useModalControls() const {data: preferences} = usePreferencesQuery() + const navigation = useNavigation<NavigationProp>() const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isPublishing, setIsPublishing] = useState(false) @@ -302,7 +304,9 @@ export const ComposePost = ({ ) const onPressCancel = useCallback(() => { - if ( + if (textInput.current?.maybeClosePopup()) { + return + } else if ( thread.posts.some( post => post.shortenedGraphemeLength > 0 || @@ -521,12 +525,29 @@ export const ComposePost = ({ onPostSuccess?.(postSuccessData) } onClose() - toast.show( - thread.posts.length > 1 - ? _(msg`Your posts have been published`) - : replyTo - ? _(msg`Your reply has been published`) - : _(msg`Your post has been published`), + Toast.show( + <Toast.Outer> + <Toast.Icon /> + <Toast.Text> + {thread.posts.length > 1 + ? _(msg`Your posts were sent`) + : replyTo + ? _(msg`Your reply was sent`) + : _(msg`Your post was sent`)} + </Toast.Text> + {postUri && ( + <Toast.Action + label={_(msg`View post`)} + onPress={() => { + const {host: name, rkey} = new AtUri(postUri) + navigation.navigate('PostThread', {name, rkey}) + }}> + <Trans context="Action to view the post the user just created"> + View + </Trans> + </Toast.Action> + )} + </Toast.Outer>, {type: 'success'}, ) }, [ @@ -543,6 +564,7 @@ export const ComposePost = ({ replyTo, setLangPrefs, queryClient, + navigation, ]) // Preserves the referential identity passed to each post item. @@ -826,7 +848,7 @@ let ComposerPost = React.memo(function ComposerPost({ if (isNative) return // web only const [mimeType] = uri.slice('data:'.length).split(';') if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { - toast.show(_(msg`Unsupported video type: ${mimeType}`), { + Toast.show(_(msg`Unsupported video type: ${mimeType}`), { type: 'error', }) return @@ -1362,7 +1384,7 @@ function ComposerFooter({ } errors.map(error => { - toast.show(error, { + Toast.show(error, { type: 'warning', }) }) @@ -1431,7 +1453,7 @@ function ComposerFooter({ /> </Button> )} - <SelectLangBtn /> + <SelectPostLanguagesBtn /> <CharProgress count={post.shortenedGraphemeLength} style={{width: 65}} diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index 026d0ac19..9401b7975 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -1,10 +1,6 @@ import {useCallback} from 'react' import {Keyboard} from 'react-native' -import { - type ImagePickerAsset, - launchImageLibraryAsync, - UIImagePickerPreferredAssetRepresentationMode, -} from 'expo-image-picker' +import {type ImagePickerAsset} from 'expo-image-picker' import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -13,8 +9,9 @@ import { usePhotoLibraryPermission, useVideoLibraryPermission, } from '#/lib/hooks/usePermissions' +import {openUnifiedPicker} from '#/lib/media/picker' import {extractDataUriMime} from '#/lib/media/util' -import {isIOS, isNative, isWeb} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {MAX_IMAGES} from '#/view/com/composer/state/composer' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' @@ -410,7 +407,7 @@ export function SelectMediaButton({ msg`You can only select one GIF at a time.`, ), [SelectedAssetError.FileTooBig]: _( - msg`One or more of your selected files is too large. Maximum size is 100 MB.`, + msg`One or more of your selected files are too large. Maximum size is 100 MB.`, ), }[error] }) @@ -448,18 +445,7 @@ export function SelectMediaButton({ } const {assets, canceled} = await sheetWrapper( - launchImageLibraryAsync({ - exif: false, - mediaTypes: ['images', 'videos'], - quality: 1, - allowsMultipleSelection: true, - legacy: true, - base64: isWeb, - selectionLimit: isIOS ? selectionCountRemaining : undefined, - preferredAssetRepresentationMode: - UIImagePickerPreferredAssetRepresentationMode.Current, - videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000, - }), + openUnifiedPicker({selectionCountRemaining}), ) if (canceled) return @@ -481,34 +467,17 @@ export function SelectMediaButton({ label={_( msg({ message: `Add media to post`, - comment: `Accessibility label for button in composer to add photos or a video to a post`, + comment: `Accessibility label for button in composer to add images, a video, or a GIF to a post`, + }), + )} + accessibilityHint={_( + msg({ + message: `Opens device gallery to select up to ${plural(MAX_IMAGES, { + other: '# images', + })}, or a single video or GIF.`, + comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`, }), )} - accessibilityHint={ - isNative - ? _( - msg({ - message: `Opens device gallery to select up to ${plural( - MAX_IMAGES, - { - other: '# images', - }, - )}, or a single video.`, - comment: `Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change.`, - }), - ) - : _( - msg({ - message: `Opens device gallery to select up to ${plural( - MAX_IMAGES, - { - other: '# images', - }, - )}, or a single video or GIF.`, - comment: `Accessibility hint on web for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`, - }), - ) - } style={a.p_sm} variant="ghost" shape="round" diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx deleted file mode 100644 index f487b1244..000000000 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import {useCallback, useMemo} from 'react' -import {Keyboard, StyleSheet} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants' -import {usePalette} from '#/lib/hooks/usePalette' -import {isNative} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import { - hasPostLanguage, - toPostLanguages, - useLanguagePrefs, - useLanguagePrefsApi, -} from '#/state/preferences/languages' -import { - DropdownButton, - DropdownItem, - DropdownItemButton, -} from '#/view/com/util/forms/DropdownButton' -import {Text} from '#/view/com/util/text/Text' -import {codeToLanguageName} from '../../../../locale/helpers' - -export function SelectLangBtn() { - const pal = usePalette('default') - const {_} = useLingui() - const {openModal} = useModalControls() - const langPrefs = useLanguagePrefs() - const setLangPrefs = useLanguagePrefsApi() - - const onPressMore = useCallback(async () => { - if (isNative) { - if (Keyboard.isVisible()) { - Keyboard.dismiss() - } - } - openModal({name: 'post-languages-settings'}) - }, [openModal]) - - const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) - const items: DropdownItem[] = useMemo(() => { - let arr: DropdownItemButton[] = [] - - function add(commaSeparatedLangCodes: string) { - const langCodes = commaSeparatedLangCodes.split(',') - const langName = langCodes - .map(code => codeToLanguageName(code, langPrefs.appLanguage)) - .join(' + ') - - /* - * Filter out any duplicates - */ - if (arr.find((item: DropdownItemButton) => item.label === langName)) { - return - } - - arr.push({ - icon: - langCodes.every(code => - hasPostLanguage(langPrefs.postLanguage, code), - ) && langCodes.length === postLanguagesPref.length - ? ['fas', 'circle-dot'] - : ['far', 'circle'], - label: langName, - onPress() { - setLangPrefs.setPostLanguage(commaSeparatedLangCodes) - }, - }) - } - - if (postLanguagesPref.length) { - /* - * Re-join here after sanitization bc postLanguageHistory is an array of - * comma-separated strings too - */ - add(langPrefs.postLanguage) - } - - // comma-separted strings of lang codes that have been used in the past - for (const lang of langPrefs.postLanguageHistory) { - add(lang) - } - - return [ - {heading: true, label: _(msg`Post language`)}, - ...arr.slice(0, 6), - {sep: true}, - { - label: _(msg`Other...`), - onPress: onPressMore, - }, - ] - }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _]) - - return ( - <DropdownButton - type="bare" - testID="selectLangBtn" - items={items} - openUpwards - style={styles.button} - hitSlop={LANG_DROPDOWN_HITSLOP} - accessibilityLabel={_(msg`Language selection`)} - accessibilityHint=""> - {postLanguagesPref.length > 0 ? ( - <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}> - {postLanguagesPref - .map(lang => codeToLanguageName(lang, langPrefs.appLanguage)) - .join(', ')} - </Text> - ) : ( - <FontAwesomeIcon - icon="language" - style={pal.link as FontAwesomeIconStyle} - size={26} - /> - )} - </DropdownButton> - ) -} - -const styles = StyleSheet.create({ - button: { - marginHorizontal: 15, - }, - label: { - maxWidth: 100, - }, -}) diff --git a/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx b/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx new file mode 100644 index 000000000..c8ecc2b89 --- /dev/null +++ b/src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx @@ -0,0 +1,382 @@ +import {useCallback, useMemo, useState} from 'react' +import {Keyboard, useWindowDimensions, View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants' +import {languageName} from '#/locale/helpers' +import {codeToLanguageName} from '#/locale/helpers' +import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages' +import {isNative, isWeb} from '#/platform/detection' +import { + toPostLanguages, + useLanguagePrefs, + useLanguagePrefsApi, +} from '#/state/preferences/languages' +import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {atoms as a, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {SearchInput} from '#/components/forms/SearchInput' +import * as Toggle from '#/components/forms/Toggle' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +export function SelectPostLanguagesBtn() { + const {_} = useLingui() + const langPrefs = useLanguagePrefs() + const t = useTheme() + const control = Dialog.useDialogControl() + + const onPressMore = useCallback(async () => { + if (isNative) { + if (Keyboard.isVisible()) { + Keyboard.dismiss() + } + } + control.open() + }, [control]) + + const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) + + return ( + <> + <Button + testID="selectLangBtn" + onPress={onPressMore} + size="small" + hitSlop={LANG_DROPDOWN_HITSLOP} + label={_( + msg({ + message: `Post language selection`, + comment: `Accessibility label for button that opens dialog to choose post language settings`, + }), + )} + accessibilityHint={_(msg`Opens post language settings`)} + style={[a.mx_md]}> + {({pressed, hovered, focused}) => { + const color = + pressed || hovered || focused + ? t.palette.primary_300 + : t.palette.primary_500 + if (postLanguagesPref.length > 0) { + return ( + <Text + style={[ + {color}, + a.font_bold, + a.text_sm, + a.leading_snug, + {maxWidth: 100}, + ]} + numberOfLines={1}> + {postLanguagesPref + .map(lang => codeToLanguageName(lang, langPrefs.appLanguage)) + .join(', ')} + </Text> + ) + } else { + return <GlobeIcon size="xs" style={{color}} /> + } + }} + </Button> + + <LanguageDialog control={control} /> + </> + ) +} + +function LanguageDialog({control}: {control: Dialog.DialogControlProps}) { + const {height} = useWindowDimensions() + const insets = useSafeAreaInsets() + + const renderErrorBoundary = useCallback( + (error: any) => <DialogError details={String(error)} />, + [], + ) + + return ( + <Dialog.Outer + control={control} + nativeOptions={{minHeight: height - insets.top}}> + <Dialog.Handle /> + <ErrorBoundary renderError={renderErrorBoundary}> + <PostLanguagesSettingsDialogInner /> + </ErrorBoundary> + </Dialog.Outer> + ) +} + +export function PostLanguagesSettingsDialogInner() { + const control = Dialog.useDialogContext() + const [headerHeight, setHeaderHeight] = useState(0) + + const allowedLanguages = useMemo(() => { + const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce( + (acc, lang) => { + acc[lang.code2] = lang + return acc + }, + {} as Record<string, Language>, + ) + + return Object.values(uniqueLanguagesMap) + }, []) + + const langPrefs = useLanguagePrefs() + const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>( + langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage], + ) + const [search, setSearch] = useState('') + + const setLangPrefs = useLanguagePrefsApi() + const t = useTheme() + const {_} = useLingui() + + const handleClose = () => { + control.close(() => { + let langsString = checkedLanguagesCode2.join(',') + if (!langsString) { + langsString = langPrefs.primaryLanguage + } + setLangPrefs.setPostLanguage(langsString) + }) + } + + // NOTE(@elijaharita): Displayed languages are split into 3 lists for + // ordering. + const displayedLanguages = useMemo(() => { + function mapCode2List(code2List: string[]) { + return code2List.map(code2 => LANGUAGES_MAP_CODE2[code2]).filter(Boolean) + } + + // NOTE(@elijaharita): Get recent language codes and map them to language + // objects. Both the user account's saved language history and the current + // checked languages are displayed here. + const recentLanguagesCode2 = + Array.from( + new Set([...checkedLanguagesCode2, ...langPrefs.postLanguageHistory]), + ).slice(0, 5) || [] + const recentLanguages = mapCode2List(recentLanguagesCode2) + + // NOTE(@elijaharita): helper functions + const matchesSearch = (lang: Language) => + lang.name.toLowerCase().includes(search.toLowerCase()) + const isChecked = (lang: Language) => + checkedLanguagesCode2.includes(lang.code2) + const isInRecents = (lang: Language) => + recentLanguagesCode2.includes(lang.code2) + + const checkedRecent = recentLanguages.filter(isChecked) + + if (search) { + // NOTE(@elijaharita): if a search is active, we ALWAYS show checked + // items, as well as any items that match the search. + const uncheckedRecent = recentLanguages + .filter(lang => !isChecked(lang)) + .filter(matchesSearch) + const unchecked = allowedLanguages.filter(lang => !isChecked(lang)) + const all = unchecked + .filter(matchesSearch) + .filter(lang => !isInRecents(lang)) + + return { + all, + checkedRecent, + uncheckedRecent, + } + } else { + // NOTE(@elijaharita): if no search is active, we show everything. + const uncheckedRecent = recentLanguages.filter(lang => !isChecked(lang)) + const all = allowedLanguages + .filter(lang => !recentLanguagesCode2.includes(lang.code2)) + .filter(lang => !isInRecents(lang)) + + return { + all, + checkedRecent, + uncheckedRecent, + } + } + }, [ + allowedLanguages, + search, + langPrefs.postLanguageHistory, + checkedLanguagesCode2, + ]) + + const listHeader = ( + <View + style={[a.pb_xs, t.atoms.bg, isNative && a.pt_2xl]} + onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> + <View style={[a.flex_row, a.w_full, a.justify_between]}> + <View> + <Text + nativeID="dialog-title" + style={[ + t.atoms.text, + a.text_left, + a.font_bold, + a.text_xl, + a.mb_sm, + ]}> + <Trans>Choose Post Languages</Trans> + </Text> + <Text + nativeID="dialog-description" + style={[ + t.atoms.text_contrast_medium, + a.text_left, + a.text_md, + a.mb_lg, + ]}> + <Trans>Select up to 3 languages used in this post</Trans> + </Text> + </View> + + {isWeb && ( + <Button + variant="ghost" + size="small" + color="secondary" + shape="round" + label={_(msg`Close dialog`)} + onPress={handleClose}> + <ButtonIcon icon={XIcon} /> + </Button> + )} + </View> + + <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs, a.pb_0]}> + <SearchInput + value={search} + onChangeText={setSearch} + placeholder={_(msg`Search languages`)} + label={_(msg`Search languages`)} + maxLength={50} + onClearText={() => setSearch('')} + /> + </View> + </View> + ) + + const isCheckedRecentEmpty = + displayedLanguages.checkedRecent.length > 0 || + displayedLanguages.uncheckedRecent.length > 0 + + const isDisplayedLanguagesEmpty = displayedLanguages.all.length === 0 + + const flatListData = [ + ...(isCheckedRecentEmpty + ? [{type: 'header', label: _(msg`Recently used`)}] + : []), + ...displayedLanguages.checkedRecent.map(lang => ({type: 'item', lang})), + ...displayedLanguages.uncheckedRecent.map(lang => ({type: 'item', lang})), + ...(isDisplayedLanguagesEmpty + ? [] + : [{type: 'header', label: _(msg`All languages`)}]), + ...displayedLanguages.all.map(lang => ({type: 'item', lang})), + ] + + return ( + <Toggle.Group + values={checkedLanguagesCode2} + onChange={setCheckedLanguagesCode2} + type="checkbox" + maxSelections={3} + label={_(msg`Select languages`)} + style={web([a.contents])}> + <Dialog.InnerFlatList + data={flatListData} + ListHeaderComponent={listHeader} + stickyHeaderIndices={[0]} + contentContainerStyle={[a.gap_0]} + style={[isNative && a.px_lg, web({paddingBottom: 120})]} + scrollIndicatorInsets={{top: headerHeight}} + renderItem={({item, index}) => { + if (item.type === 'header') { + return ( + <Text + key={index} + style={[ + a.px_0, + a.py_md, + a.font_bold, + a.text_xs, + t.atoms.text_contrast_low, + a.pt_3xl, + ]}> + {item.label} + </Text> + ) + } + const lang = item.lang + + return ( + <Toggle.Item + key={lang.code2} + name={lang.code2} + label={languageName(lang, langPrefs.appLanguage)} + style={[ + t.atoms.border_contrast_low, + a.border_b, + a.rounded_0, + a.px_0, + a.py_md, + ]}> + <Toggle.LabelText style={[a.flex_1]}> + {languageName(lang, langPrefs.appLanguage)} + </Toggle.LabelText> + <Toggle.Checkbox /> + </Toggle.Item> + ) + }} + footer={ + <Dialog.FlatListFooter> + <Button + label={_(msg`Close dialog`)} + onPress={handleClose} + color="primary" + size="large"> + <ButtonText> + <Trans>Done</Trans> + </ButtonText> + </Button> + </Dialog.FlatListFooter> + } + /> + </Toggle.Group> + ) +} + +function DialogError({details}: {details?: string}) { + const {_} = useLingui() + const control = Dialog.useDialogContext() + + return ( + <Dialog.ScrollableInner + style={a.gap_md} + label={_(msg`An error has occurred`)}> + <Dialog.Close /> + <ErrorScreen + title={_(msg`Oh no!`)} + message={_( + msg`There was an unexpected issue in the application. Please let us know if this happened to you!`, + )} + details={details} + /> + <Button + label={_(msg`Close dialog`)} + onPress={() => control.close()} + color="primary" + size="large"> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + </Dialog.ScrollableInner> + ) +} diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index ea92d0b91..8b3e61b0e 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,7 +1,6 @@ -import React, { - type ComponentProps, - forwardRef, +import { useCallback, + useImperativeHandle, useMemo, useRef, useState, @@ -9,7 +8,6 @@ import React, { import { type NativeSyntheticEvent, Text as RNText, - type TextInput as RNTextInput, type TextInputSelectionChangeEventData, View, } from 'react-native' @@ -33,57 +31,38 @@ import { import {atoms as a, useAlf} from '#/alf' import {normalizeTextStyles} from '#/alf/typography' import {Autocomplete} from './mobile/Autocomplete' - -export interface TextInputRef { - focus: () => void - blur: () => void - getCursorPosition: () => DOMRect | undefined -} - -interface TextInputProps extends ComponentProps<typeof RNTextInput> { - richtext: RichText - placeholder: string - webForceMinHeight: boolean - hasRightPadding: boolean - isActive: boolean - setRichText: (v: RichText) => void - onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => void - onNewLink: (uri: string) => void - onError: (err: string) => void -} +import {type TextInputProps} from './TextInput.types' interface Selection { start: number end: number } -export const TextInput = forwardRef(function TextInputImpl( - { - richtext, - placeholder, - hasRightPadding, - setRichText, - onPhotoPasted, - onNewLink, - onError, - ...props - }: TextInputProps, +export function TextInput({ ref, -) { + richtext, + placeholder, + hasRightPadding, + setRichText, + onPhotoPasted, + onNewLink, + onError, + ...props +}: TextInputProps) { const {theme: t, fonts} = useAlf() const textInput = useRef<PasteInputRef>(null) const textInputSelection = useRef<Selection>({start: 0, end: 0}) const theme = useTheme() const [autocompletePrefix, setAutocompletePrefix] = useState('') - const prevLength = React.useRef(richtext.length) + const prevLength = useRef(richtext.length) - React.useImperativeHandle(ref, () => ({ + useImperativeHandle(ref, () => ({ focus: () => textInput.current?.focus(), blur: () => { textInput.current?.blur() }, getCursorPosition: () => undefined, // Not implemented on native + maybeClosePopup: () => false, // Not needed on native })) const pastSuggestedUris = useRef(new Set<string>()) @@ -185,7 +164,7 @@ export const TextInput = forwardRef(function TextInputImpl( [onChangeText, richtext, setAutocompletePrefix], ) - const inputTextStyle = React.useMemo(() => { + const inputTextStyle = useMemo(() => { const style = normalizeTextStyles( [a.text_lg, a.leading_snug, t.atoms.text], { @@ -277,4 +256,4 @@ export const TextInput = forwardRef(function TextInputImpl( /> </View> ) -}) +} diff --git a/src/view/com/composer/text-input/TextInput.types.ts b/src/view/com/composer/text-input/TextInput.types.ts new file mode 100644 index 000000000..fab2bc32f --- /dev/null +++ b/src/view/com/composer/text-input/TextInput.types.ts @@ -0,0 +1,42 @@ +import {type TextInput} from 'react-native' +import {type RichText} from '@atproto/api' + +export type TextInputRef = { + focus: () => void + blur: () => void + /** + * @platform web + */ + getCursorPosition: () => + | {left: number; right: number; top: number; bottom: number} + | undefined + /** + * Closes the autocomplete popup if it is open. + * Returns `true` if the popup was closed, `false` otherwise. + * + * @platform web + */ + maybeClosePopup: () => boolean +} + +export type TextInputProps = { + ref: React.Ref<TextInputRef> + richtext: RichText + webForceMinHeight: boolean + hasRightPadding: boolean + isActive: boolean + setRichText: (v: RichText) => void + onPhotoPasted: (uri: string) => void + onPressPublish: (richtext: RichText) => void + onNewLink: (uri: string) => void + onError: (err: string) => void + onFocus: () => void +} & Pick< + React.ComponentProps<typeof TextInput>, + | 'placeholder' + | 'autoFocus' + | 'style' + | 'accessible' + | 'accessibilityLabel' + | 'accessibilityHint' +> diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index cb7ed194a..9f6cc6ae2 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,4 +1,11 @@ -import React, {useRef} from 'react' +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import {StyleSheet, View} from 'react-native' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {AppBskyRichtextFacet, RichText} from '@atproto/api' @@ -16,7 +23,6 @@ import {EditorContent, type JSONContent, useEditor} from '@tiptap/react' import Graphemer from 'graphemer' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' -import {usePalette} from '#/lib/hooks/usePalette' import {blobToDataUri, isUriImage} from '#/lib/media/util' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import { @@ -27,57 +33,34 @@ import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEm import {atoms as a, useAlf} from '#/alf' import {normalizeTextStyles} from '#/alf/typography' import {Portal} from '#/components/Portal' -import {Text} from '../../util/text/Text' -import {createSuggestion} from './web/Autocomplete' +import {Text} from '#/components/Typography' +import {type TextInputProps} from './TextInput.types' +import {type AutocompleteRef, createSuggestion} from './web/Autocomplete' import {type Emoji} from './web/EmojiPicker' import {LinkDecorator} from './web/LinkDecorator' import {TagDecorator} from './web/TagDecorator' -export interface TextInputRef { - focus: () => void - blur: () => void - getCursorPosition: () => DOMRect | undefined -} - -interface TextInputProps { - richtext: RichText - placeholder: string - suggestedLinks: Set<string> - webForceMinHeight: boolean - hasRightPadding: boolean - isActive: boolean - setRichText: (v: RichText | ((v: RichText) => RichText)) => void - onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => void - onNewLink: (uri: string) => void - onError: (err: string) => void - onFocus: () => void -} - -export const TextInput = React.forwardRef(function TextInputImpl( - { - richtext, - placeholder, - webForceMinHeight, - hasRightPadding, - isActive, - setRichText, - onPhotoPasted, - onPressPublish, - onNewLink, - onFocus, - }: // onError, TODO - TextInputProps, +export function TextInput({ ref, -) { + richtext, + placeholder, + webForceMinHeight, + hasRightPadding, + isActive, + setRichText, + onPhotoPasted, + onPressPublish, + onNewLink, + onFocus, +}: TextInputProps) { const {theme: t, fonts} = useAlf() const autocomplete = useActorAutocompleteFn() - const pal = usePalette('default') const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') - const [isDropping, setIsDropping] = React.useState(false) + const [isDropping, setIsDropping] = useState(false) + const autocompleteRef = useRef<AutocompleteRef>(null) - const extensions = React.useMemo( + const extensions = useMemo( () => [ Document, LinkDecorator, @@ -86,7 +69,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( HTMLAttributes: { class: 'mention', }, - suggestion: createSuggestion({autocomplete}), + suggestion: createSuggestion({autocomplete, autocompleteRef}), }), Paragraph, Placeholder.configure({ @@ -99,7 +82,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( [autocomplete, placeholder], ) - React.useEffect(() => { + useEffect(() => { if (!isActive) { return } @@ -109,7 +92,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, [onPressPublish, isActive]) - React.useEffect(() => { + useEffect(() => { if (!isActive) { return } @@ -119,7 +102,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, [isActive, onPhotoPasted]) - React.useEffect(() => { + useEffect(() => { if (!isActive) { return } @@ -296,13 +279,13 @@ export const TextInput = React.forwardRef(function TextInputImpl( [modeClass], ) - const onEmojiInserted = React.useCallback( + const onEmojiInserted = useCallback( (emoji: Emoji) => { editor?.chain().focus().insertContent(emoji.native).run() }, [editor], ) - React.useEffect(() => { + useEffect(() => { if (!isActive) { return } @@ -312,7 +295,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, [onEmojiInserted, isActive]) - React.useImperativeHandle(ref, () => ({ + useImperativeHandle(ref, () => ({ focus: () => { editor?.chain().focus() }, @@ -323,9 +306,10 @@ export const TextInput = React.forwardRef(function TextInputImpl( const pos = editor?.state.selection.$anchor.pos return pos ? editor?.view.coordsAtPos(pos) : undefined }, + maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false, })) - const inputStyle = React.useMemo(() => { + const inputStyle = useMemo(() => { const style = normalizeTextStyles( [a.text_lg, a.leading_snug, t.atoms.text], { @@ -360,10 +344,20 @@ export const TextInput = React.forwardRef(function TextInputImpl( style={styles.dropContainer} entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)}> - <View style={[pal.view, pal.border, styles.dropModal]}> + <View + style={[ + t.atoms.bg, + t.atoms.border_contrast_low, + styles.dropModal, + ]}> <Text - type="lg" - style={[pal.text, pal.borderDark, styles.dropText]}> + style={[ + a.text_lg, + a.font_bold, + t.atoms.text_contrast_medium, + t.atoms.border_contrast_high, + styles.dropText, + ]}> <Trans>Drop to add images</Trans> </Text> </View> @@ -372,7 +366,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( )} </> ) -}) +} function editorJsonToText( json: JSONContent, diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 94ecb53cc..1a95736c3 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -1,6 +1,6 @@ import {forwardRef, useEffect, useImperativeHandle, useState} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' -import {type AppBskyActorDefs} from '@atproto/api' +import {Pressable, View} from 'react-native' +import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' import {Trans} from '@lingui/macro' import {ReactRenderer} from '@tiptap/react' import { @@ -10,25 +10,26 @@ import { } from '@tiptap/suggestion' import tippy, {type Instance as TippyInstance} from 'tippy.js' -import {usePalette} from '#/lib/hooks/usePalette' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' -import {Text} from '#/view/com/util/text/Text' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a} from '#/alf' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' -import {useGrapheme} from '../hooks/useGrapheme' +import {atoms as a, useTheme} from '#/alf' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' interface MentionListRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean } +export interface AutocompleteRef { + maybeClose: () => boolean +} + export function createSuggestion({ autocomplete, + autocompleteRef, }: { autocomplete: ActorAutocompleteFn + autocompleteRef: React.Ref<AutocompleteRef> }): Omit<SuggestionOptions, 'editor'> { return { async items({query}) { @@ -40,10 +41,15 @@ export function createSuggestion({ let component: ReactRenderer<MentionListRef> | undefined let popup: TippyInstance[] | undefined + const hide = () => { + popup?.[0]?.destroy() + component?.destroy() + } + return { onStart: props => { component = new ReactRenderer(MentionList, { - props, + props: {...props, autocompleteRef, hide}, editor: props.editor, }) @@ -78,204 +84,163 @@ export function createSuggestion({ onKeyDown(props) { if (props.event.key === 'Escape') { - popup?.[0]?.hide() - - return true + return false } return component?.ref?.onKeyDown(props) || false }, onExit() { - popup?.[0]?.destroy() - component?.destroy() + hide() }, } }, } } -const MentionList = forwardRef<MentionListRef, SuggestionProps>( - function MentionListImpl(props: SuggestionProps, ref) { - const [selectedIndex, setSelectedIndex] = useState(0) - const pal = usePalette('default') +const MentionList = forwardRef< + MentionListRef, + SuggestionProps & { + autocompleteRef: React.Ref<AutocompleteRef> + hide: () => void + } +>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) { + const [selectedIndex, setSelectedIndex] = useState(0) + const t = useTheme() + const moderationOpts = useModerationOpts() - const selectItem = (index: number) => { - const item = props.items[index] + const selectItem = (index: number) => { + const item = items[index] - if (item) { - props.command({id: item.handle}) - } + if (item) { + command({id: item.handle}) } + } - const upHandler = () => { - setSelectedIndex( - (selectedIndex + props.items.length - 1) % props.items.length, - ) - } + const upHandler = () => { + setSelectedIndex((selectedIndex + items.length - 1) % items.length) + } - const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % props.items.length) - } + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length) + } - const enterHandler = () => { - selectItem(selectedIndex) - } + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [items]) + + useImperativeHandle(autocompleteRef, () => ({ + maybeClose: () => { + hide() + return true + }, + })) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter' || event.key === 'Tab') { + enterHandler() + return true + } + + return false + }, + })) - useEffect(() => setSelectedIndex(0), [props.items]) - - useImperativeHandle(ref, () => ({ - onKeyDown: ({event}) => { - if (event.key === 'ArrowUp') { - upHandler() - return true - } - - if (event.key === 'ArrowDown') { - downHandler() - return true - } - - if (event.key === 'Enter' || event.key === 'Tab') { - enterHandler() - return true - } - - return false - }, - })) - - const {items} = props - - return ( - <div className="items"> - <View style={[pal.borderDark, pal.view, styles.container]}> - {items.length > 0 ? ( - items.map((item, index) => { - const isSelected = selectedIndex === index - - return ( - <AutocompleteProfileCard - key={item.handle} - profile={item} - isSelected={isSelected} - itemIndex={index} - totalItems={items.length} - onPress={() => { - selectItem(index) - }} - /> - ) - }) - ) : ( - <Text type="sm" style={[pal.text, styles.noResult]}> - <Trans>No result</Trans> - </Text> - )} - </View> - </div> - ) - }, -) + if (!moderationOpts) return null + + return ( + <div className="items"> + <View + style={[ + t.atoms.border_contrast_low, + t.atoms.bg, + a.rounded_sm, + a.border, + a.p_xs, + {width: 300}, + ]}> + {items.length > 0 ? ( + items.map((item, index) => { + const isSelected = selectedIndex === index + + return ( + <AutocompleteProfileCard + key={item.handle} + profile={item} + isSelected={isSelected} + onPress={() => selectItem(index)} + onHover={() => setSelectedIndex(index)} + moderationOpts={moderationOpts} + /> + ) + }) + ) : ( + <Text style={[a.text_sm, a.px_md, a.py_md]}> + <Trans>No result</Trans> + </Text> + )} + </View> + </div> + ) +}) function AutocompleteProfileCard({ profile, isSelected, - itemIndex, - totalItems, onPress, + onHover, + moderationOpts, }: { profile: AppBskyActorDefs.ProfileViewBasic isSelected: boolean - itemIndex: number - totalItems: number onPress: () => void + onHover: () => void + moderationOpts: ModerationOpts }) { - const pal = usePalette('default') - const {getGraphemeString} = useGrapheme() - const {name: displayName} = getGraphemeString( - sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)), - 30, // Heuristic value; can be modified - ) - const state = useSimpleVerificationState({ - profile, - }) + const t = useTheme() + return ( <Pressable style={[ - isSelected ? pal.viewLight : undefined, - pal.borderDark, - styles.mentionContainer, - itemIndex === 0 - ? styles.firstMention - : itemIndex === totalItems - 1 - ? styles.lastMention - : undefined, + isSelected && t.atoms.bg_contrast_25, + a.align_center, + a.justify_between, + a.flex_row, + a.px_md, + a.py_sm, + a.gap_2xl, + a.rounded_xs, + a.transition_color, ]} onPress={onPress} + onPointerEnter={onHover} accessibilityRole="button"> - <View style={[styles.avatarAndDisplayName, a.flex_1]}> - <UserAvatar - avatar={profile.avatar ?? null} - size={26} - type={profile.associated?.labeler ? 'labeler' : 'user'} - /> - <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> - <Text emoji style={[pal.text]} numberOfLines={1}> - {displayName} - </Text> - {state.isVerified && ( - <View> - <VerificationCheck - width={12} - verifier={state.role === 'verifier'} - /> - </View> - )} - </View> - </View> - <View> - <Text type="xs" style={pal.textLight} numberOfLines={1}> - {sanitizeHandle(profile.handle, '@')} - </Text> + <View style={[a.flex_1]}> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + disabledPreview + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + </ProfileCard.Header> </View> </Pressable> ) } - -const styles = StyleSheet.create({ - container: { - width: 500, - borderRadius: 6, - borderWidth: 1, - borderStyle: 'solid', - padding: 4, - }, - mentionContainer: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - flexDirection: 'row', - paddingHorizontal: 12, - paddingVertical: 8, - gap: 16, - }, - firstMention: { - borderTopLeftRadius: 2, - borderTopRightRadius: 2, - }, - lastMention: { - borderBottomLeftRadius: 2, - borderBottomRightRadius: 2, - }, - avatarAndDisplayName: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - noResult: { - paddingHorizontal: 12, - paddingVertical: 8, - }, -}) diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index e8a177a8d..0f93e66e3 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react' import {type NavigationProp, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {VIDEO_FEED_URIS} from '#/lib/constants' +import {DISCOVER_FEED_URI, VIDEO_FEED_URIS} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {ComposeIcon2} from '#/lib/icons' import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' @@ -17,9 +17,12 @@ import {isNative} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' import {useSetHomeBadge} from '#/state/home-badge' -import {type SavedFeedSourceInfo} from '#/state/queries/feed' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' -import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' +import {type FeedSourceInfo} from '#/state/queries/feed' +import { + type FeedDescriptor, + type FeedParams, + RQKEY as FEED_RQKEY, +} from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' @@ -51,7 +54,7 @@ export function FeedPage({ renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element savedFeedConfig?: AppBskyActorDefs.SavedFeed - feedInfo: SavedFeedSourceInfo + feedInfo: FeedSourceInfo }) { const {hasSession} = useSession() const {_} = useLingui() @@ -61,7 +64,7 @@ export function FeedPage({ const [isScrolledDown, setIsScrolledDown] = useState(false) const setMinimalShellMode = useSetMinimalShellMode() const headerOffset = useHeaderOffset() - const feedFeedback = useFeedFeedback(feed, hasSession) + const feedFeedback = useFeedFeedback(feedInfo, hasSession) const scrollElRef = useRef<ListMethods>(null) const [hasNew, setHasNew] = useState(false) const setHomeBadge = useSetHomeBadge() @@ -127,8 +130,12 @@ export function FeedPage({ }, [scrollToTop, feed, queryClient]) const shouldPrefetch = isNative && isPageAdjacent + const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI return ( - <View testID={testID}> + <View + testID={testID} + // @ts-expect-error web only -sfn + dataSet={{nosnippet: isDiscoverFeed ? '' : undefined}}> <MainScrollProvider> <FeedFeedbackProvider value={feedFeedback}> <PostFeed diff --git a/src/view/com/modals/ChangePassword.tsx b/src/view/com/modals/ChangePassword.tsx deleted file mode 100644 index 9b96e7db0..000000000 --- a/src/view/com/modals/ChangePassword.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import {useState} from 'react' -import { - ActivityIndicator, - SafeAreaView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import * as EmailValidator from 'email-validator' - -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {cleanError, isNetworkError} from '#/lib/strings/errors' -import {checkAndFormatResetCode} from '#/lib/strings/password' -import {colors, s} from '#/lib/styles' -import {logger} from '#/logger' -import {isAndroid, isWeb} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import {useAgent, useSession} from '#/state/session' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {Button} from '../util/forms/Button' -import {Text} from '../util/text/Text' -import {ScrollView} from './util' -import {TextInput} from './util' - -enum Stages { - RequestCode, - ChangePassword, - Done, -} - -export const snapPoints = isAndroid ? ['90%'] : ['45%'] - -export function Component() { - const pal = usePalette('default') - const {currentAccount} = useSession() - const agent = useAgent() - const {_} = useLingui() - const [stage, setStage] = useState<Stages>(Stages.RequestCode) - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [resetCode, setResetCode] = useState<string>('') - const [newPassword, setNewPassword] = useState<string>('') - const [error, setError] = useState<string>('') - const {isMobile} = useWebMediaQueries() - const {closeModal} = useModalControls() - - const onRequestCode = async () => { - if ( - !currentAccount?.email || - !EmailValidator.validate(currentAccount.email) - ) { - return setError(_(msg`Your email appears to be invalid.`)) - } - - setError('') - setIsProcessing(true) - try { - await agent.com.atproto.server.requestPasswordReset({ - email: currentAccount.email, - }) - setStage(Stages.ChangePassword) - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to request password reset', {error: e}) - if (isNetworkError(e)) { - setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), - ) - } else { - setError(cleanError(errMsg)) - } - } finally { - setIsProcessing(false) - } - } - - const onChangePassword = async () => { - const formattedCode = checkAndFormatResetCode(resetCode) - if (!formattedCode) { - setError( - _( - msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, - ), - ) - return - } - if (!newPassword) { - setError( - _(msg`Please enter a password. It must be at least 8 characters long.`), - ) - return - } - if (newPassword.length < 8) { - setError(_(msg`Password must be at least 8 characters long.`)) - return - } - - setError('') - setIsProcessing(true) - try { - await agent.com.atproto.server.resetPassword({ - token: formattedCode, - password: newPassword, - }) - setStage(Stages.Done) - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to set new password', {error: e}) - if (isNetworkError(e)) { - setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), - ) - } else { - setError(cleanError(errMsg)) - } - } finally { - setIsProcessing(false) - } - } - - const onBlur = () => { - const formattedCode = checkAndFormatResetCode(resetCode) - if (!formattedCode) { - setError( - _( - msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, - ), - ) - return - } - setResetCode(formattedCode) - } - - return ( - <SafeAreaView style={[pal.view, s.flex1]}> - <ScrollView - contentContainerStyle={[ - styles.container, - isMobile && styles.containerMobile, - ]} - keyboardShouldPersistTaps="handled"> - <View> - <View style={styles.titleSection}> - <Text type="title-lg" style={[pal.text, styles.title]}> - {stage !== Stages.Done - ? _(msg`Change Password`) - : _(msg`Password Changed`)} - </Text> - </View> - - <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> - {stage === Stages.RequestCode ? ( - <Trans> - If you want to change your password, we will send you a code to - verify that this is your account. - </Trans> - ) : stage === Stages.ChangePassword ? ( - <Trans> - Enter the code you received to change your password. - </Trans> - ) : ( - <Trans>Your password has been changed successfully!</Trans> - )} - </Text> - - {stage === Stages.RequestCode && ( - <View style={[s.flexRow, s.justifyCenter, s.mt10]}> - <TouchableOpacity - testID="skipSendEmailButton" - onPress={() => setStage(Stages.ChangePassword)} - accessibilityRole="button" - accessibilityLabel={_(msg`Go to next`)} - accessibilityHint={_(msg`Navigates to the next screen`)}> - <Text type="xl" style={[pal.link, s.pr5]}> - <Trans>Already have a code?</Trans> - </Text> - </TouchableOpacity> - </View> - )} - {stage === Stages.ChangePassword && ( - <View style={[pal.border, styles.group]}> - <View style={[styles.groupContent]}> - <FontAwesomeIcon - icon="ticket" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="codeInput" - style={[pal.text, styles.textInput]} - placeholder={_(msg`Reset code`)} - placeholderTextColor={pal.colors.textLight} - value={resetCode} - onChangeText={setResetCode} - onFocus={() => setError('')} - onBlur={onBlur} - accessible={true} - accessibilityLabel={_(msg`Reset Code`)} - accessibilityHint="" - autoCapitalize="none" - autoCorrect={false} - autoComplete="off" - /> - </View> - <View - style={[ - pal.borderDark, - styles.groupContent, - styles.groupBottom, - ]}> - <FontAwesomeIcon - icon="lock" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="codeInput" - style={[pal.text, styles.textInput]} - placeholder={_(msg`New password`)} - placeholderTextColor={pal.colors.textLight} - onChangeText={setNewPassword} - secureTextEntry - accessible={true} - accessibilityLabel={_(msg`New Password`)} - accessibilityHint="" - autoCapitalize="none" - autoComplete="new-password" - /> - </View> - </View> - )} - {error ? ( - <ErrorMessage message={error} style={styles.error} /> - ) : undefined} - </View> - <View style={[styles.btnContainer]}> - {isProcessing ? ( - <View style={styles.btn}> - <ActivityIndicator color="#fff" /> - </View> - ) : ( - <View style={{gap: 6}}> - {stage === Stages.RequestCode && ( - <Button - testID="requestChangeBtn" - type="primary" - onPress={onRequestCode} - accessibilityLabel={_(msg`Request Code`)} - accessibilityHint="" - label={_(msg`Request Code`)} - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - )} - {stage === Stages.ChangePassword && ( - <Button - testID="confirmBtn" - type="primary" - onPress={onChangePassword} - accessibilityLabel={_(msg`Next`)} - accessibilityHint="" - label={_(msg`Next`)} - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - )} - <Button - testID="cancelBtn" - type={stage !== Stages.Done ? 'default' : 'primary'} - onPress={() => { - closeModal() - }} - accessibilityLabel={ - stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`) - } - accessibilityHint="" - label={stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`)} - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - </View> - )} - </View> - </ScrollView> - </SafeAreaView> - ) -} - -const styles = StyleSheet.create({ - container: { - justifyContent: 'space-between', - }, - containerMobile: { - paddingHorizontal: 18, - paddingBottom: 35, - }, - titleSection: { - paddingTop: isWeb ? 0 : 4, - paddingBottom: isWeb ? 14 : 10, - }, - title: { - textAlign: 'center', - fontWeight: '600', - marginBottom: 5, - }, - error: { - borderRadius: 6, - }, - textInput: { - width: '100%', - paddingHorizontal: 14, - paddingVertical: 10, - fontSize: 16, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - backgroundColor: colors.blue3, - }, - btnContainer: { - paddingTop: 20, - }, - group: { - borderWidth: 1, - borderRadius: 10, - marginVertical: 20, - }, - groupLabel: { - paddingHorizontal: 20, - paddingBottom: 5, - }, - groupContent: { - flexDirection: 'row', - alignItems: 'center', - }, - groupBottom: { - borderTopWidth: 1, - }, - groupContentIcon: { - marginLeft: 10, - }, -}) diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 5e188ee06..80ff15768 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -10,6 +10,7 @@ import {LinearGradient} from 'expo-linear-gradient' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {DM_SERVICE_HEADERS} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {cleanError} from '#/lib/strings/errors' @@ -17,7 +18,6 @@ import {colors, gradients, s} from '#/lib/styles' import {useTheme} from '#/lib/ThemeContext' import {isAndroid, isWeb} from '#/platform/detection' import {useModalControls} from '#/state/modals' -import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent, useSession, useSessionApi} from '#/state/session' import {atoms as a, useTheme as useNewTheme} from '#/alf' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index f9afd183e..79971e660 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -7,12 +7,10 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useModalControls, useModals} from '#/state/modals' import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' import * as DeleteAccountModal from './DeleteAccount' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' -import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as UserAddRemoveListsModal from './UserAddRemoveLists' const DEFAULT_SNAPPOINTS = ['90%'] @@ -61,12 +59,6 @@ export function ModalsContainer() { } else if (activeModal?.name === 'content-languages-settings') { snapPoints = ContentLanguagesSettingsModal.snapPoints element = <ContentLanguagesSettingsModal.Component /> - } else if (activeModal?.name === 'post-languages-settings') { - snapPoints = PostLanguagesSettingsModal.snapPoints - element = <PostLanguagesSettingsModal.Component /> - } else if (activeModal?.name === 'change-password') { - snapPoints = ChangePasswordModal.snapPoints - element = <ChangePasswordModal.Component /> } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 3eb744380..d0799a390 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -6,12 +6,10 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' -import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' import * as DeleteAccountModal from './DeleteAccount' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' -import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as UserAddRemoveLists from './UserAddRemoveLists' export function ModalsContainer() { @@ -60,10 +58,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <InviteCodesModal.Component /> } else if (modal.name === 'content-languages-settings') { element = <ContentLanguagesSettingsModal.Component /> - } else if (modal.name === 'post-languages-settings') { - element = <PostLanguagesSettingsModal.Component /> - } else if (modal.name === 'change-password') { - element = <ChangePasswordModal.Component /> } else { return null } diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx deleted file mode 100644 index 8c2969674..000000000 --- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {Trans} from '@lingui/macro' - -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {deviceLanguageCodes} from '#/locale/deviceLocales' -import {languageName} from '#/locale/helpers' -import {useModalControls} from '#/state/modals' -import { - hasPostLanguage, - useLanguagePrefs, - useLanguagePrefsApi, -} from '#/state/preferences/languages' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' -import {Text} from '../../util/text/Text' -import {ScrollView} from '../util' -import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' - -export const snapPoints = ['100%'] - -export function Component() { - const {closeModal} = useModalControls() - const langPrefs = useLanguagePrefs() - const setLangPrefs = useLanguagePrefsApi() - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() - const onPressDone = React.useCallback(() => { - closeModal() - }, [closeModal]) - - const languages = React.useMemo(() => { - const langs = LANGUAGES.filter( - lang => - !!lang.code2.trim() && - LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3, - ) - // sort so that device & selected languages are on top, then alphabetically - langs.sort((a, b) => { - const hasA = - hasPostLanguage(langPrefs.postLanguage, a.code2) || - deviceLanguageCodes.includes(a.code2) - const hasB = - hasPostLanguage(langPrefs.postLanguage, b.code2) || - deviceLanguageCodes.includes(b.code2) - if (hasA === hasB) return a.name.localeCompare(b.name) - if (hasA) return -1 - return 1 - }) - return langs - }, [langPrefs]) - - const onPress = React.useCallback( - (code2: string) => { - setLangPrefs.togglePostLanguage(code2) - }, - [setLangPrefs], - ) - - return ( - <View - testID="postLanguagesModal" - style={[ - pal.view, - styles.container, - // @ts-ignore vh is on web only - isMobile - ? { - paddingTop: 20, - } - : { - maxHeight: '90vh', - }, - ]}> - <Text style={[pal.text, styles.title]}> - <Trans>Post Languages</Trans> - </Text> - <Text style={[pal.text, styles.description]}> - <Trans>Which languages are used in this post?</Trans> - </Text> - <ScrollView style={styles.scrollContainer}> - {languages.map(lang => { - const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2) - - // enforce a max of 3 selections for post languages - let isDisabled = false - if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) { - isDisabled = true - } - - return ( - <ToggleButton - key={lang.code2} - label={languageName(lang, langPrefs.appLanguage)} - isSelected={isSelected} - onPress={() => (isDisabled ? undefined : onPress(lang.code2))} - style={[ - pal.border, - styles.languageToggle, - isDisabled && styles.dimmed, - ]} - /> - ) - })} - <View - style={{ - height: isMobile ? 60 : 0, - }} - /> - </ScrollView> - <ConfirmLanguagesButton onPress={onPressDone} /> - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - title: { - textAlign: 'center', - fontWeight: '600', - fontSize: 24, - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 16, - marginBottom: 10, - }, - scrollContainer: { - flex: 1, - paddingHorizontal: 10, - }, - languageToggle: { - borderTopWidth: 1, - borderRadius: 0, - paddingHorizontal: 6, - paddingVertical: 12, - }, - dimmed: { - opacity: 0.5, - }, -}) diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index dc048bd26..ce774e888 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -31,6 +31,7 @@ import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {MAX_POST_LINES} from '#/lib/constants' +import {DM_SERVICE_HEADERS} from '#/lib/constants' import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' @@ -41,7 +42,6 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' import {logger} from '#/logger' -import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {type FeedNotification} from '#/state/queries/notifications/feed' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useAgent} from '#/state/session' diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx deleted file mode 100644 index bbf9f4a20..000000000 --- a/src/view/com/post-thread/PostThread.tsx +++ /dev/null @@ -1,910 +0,0 @@ -import React, {memo, useRef, useState} from 'react' -import {useWindowDimensions, View} from 'react-native' -import {runOnJS, useAnimatedStyle} from 'react-native-reanimated' -import Animated from 'react-native-reanimated' -import { - AppBskyFeedDefs, - type AppBskyFeedThreadgate, - moderatePost, -} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {HITSLOP_10} from '#/lib/constants' -import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {useSetTitle} from '#/lib/hooks/useSetTitle' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {ScrollProvider} from '#/lib/ScrollContext' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {cleanError} from '#/lib/strings/errors' -import {isAndroid, isNative, isWeb} from '#/platform/detection' -import {useFeedFeedback} from '#/state/feed-feedback' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import { - fillThreadModerationCache, - sortThread, - type ThreadBlocked, - type ThreadModerationCache, - type ThreadNode, - type ThreadNotFound, - type ThreadPost, - usePostThreadQuery, -} from '#/state/queries/post-thread' -import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' -import {usePreferencesQuery} from '#/state/queries/preferences' -import {useSession} from '#/state/session' -import {useShellLayout} from '#/state/shell/shell-layout' -import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {useUnstablePostSource} from '#/state/unstable-post-source' -import {List, type ListMethods} from '#/view/com/util/List' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' -import {Header} from '#/components/Layout' -import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' -import * as Menu from '#/components/Menu' -import {Text} from '#/components/Typography' -import {PostThreadComposePrompt} from './PostThreadComposePrompt' -import {PostThreadItem} from './PostThreadItem' -import {PostThreadLoadMore} from './PostThreadLoadMore' -import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' - -// FlatList maintainVisibleContentPosition breaks if too many items -// are prepended. This seems to be an optimal number based on *shrug*. -const PARENTS_CHUNK_SIZE = 15 - -const MAINTAIN_VISIBLE_CONTENT_POSITION = { - // We don't insert any elements before the root row while loading. - // So the row we want to use as the scroll anchor is the first row. - minIndexForVisible: 0, -} - -const REPLY_PROMPT = {_reactKey: '__reply__'} -const LOAD_MORE = {_reactKey: '__load_more__'} -const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'} -const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'} - -enum HiddenRepliesState { - Hide, - Show, - ShowAndOverridePostHider, -} - -type YieldedItem = - | ThreadPost - | ThreadBlocked - | ThreadNotFound - | typeof SHOW_HIDDEN_REPLIES - | typeof SHOW_MUTED_REPLIES -type RowItem = - | YieldedItem - // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. - | typeof REPLY_PROMPT - | typeof LOAD_MORE - -type ThreadSkeletonParts = { - parents: YieldedItem[] - highlightedPost: ThreadNode - replies: YieldedItem[] -} - -const keyExtractor = (item: RowItem) => { - return item._reactKey -} - -export function PostThread({uri}: {uri: string}) { - const {hasSession, currentAccount} = useSession() - const {_} = useLingui() - const t = useTheme() - const {isMobile} = useWebMediaQueries() - const initialNumToRender = useInitialNumToRender() - const {height: windowHeight} = useWindowDimensions() - const [hiddenRepliesState, setHiddenRepliesState] = React.useState( - HiddenRepliesState.Hide, - ) - const headerRef = React.useRef<View | null>(null) - const anchorPostSource = useUnstablePostSource(uri) - const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) - - const {data: preferences} = usePreferencesQuery() - const { - isFetching, - isError: isThreadError, - error: threadError, - refetch, - data: {thread, threadgate} = {}, - dataUpdatedAt: fetchedAt, - } = usePostThreadQuery(uri) - - // The original source of truth for these are the server settings. - const serverPrefs = preferences?.threadViewPrefs - const serverPrioritizeFollowedUsers = - serverPrefs?.prioritizeFollowedUsers ?? true - const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false - const serverSortReplies = serverPrefs?.sort ?? 'hotness' - - // However, we also need these to work locally for PWI (without persistence). - // So we're mirroring them locally. - const prioritizeFollowedUsers = serverPrioritizeFollowedUsers - const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled) - const [sortReplies, setSortReplies] = useState(serverSortReplies) - - // We'll reset the local state if new server state flows down to us. - const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) - if (prevServerPrefs !== serverPrefs) { - setPrevServerPrefs(serverPrefs) - setTreeViewEnabled(serverTreeViewEnabled) - setSortReplies(serverSortReplies) - } - - // And we'll update the local state when mutating the server prefs. - const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation() - function updateTreeViewEnabled(newTreeViewEnabled: boolean) { - setTreeViewEnabled(newTreeViewEnabled) - if (hasSession) { - mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled}) - } - } - function updateSortReplies(newSortReplies: string) { - setSortReplies(newSortReplies) - if (hasSession) { - mutateThreadViewPrefs({sort: newSortReplies}) - } - } - - const treeView = React.useMemo( - () => treeViewEnabled && hasBranchingReplies(thread), - [treeViewEnabled, thread], - ) - - const rootPost = thread?.type === 'post' ? thread.post : undefined - const rootPostRecord = thread?.type === 'post' ? thread.record : undefined - const threadgateRecord = threadgate?.record as - | AppBskyFeedThreadgate.Record - | undefined - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ - threadgateRecord, - }) - - const moderationOpts = useModerationOpts() - const isNoPwi = React.useMemo(() => { - const mod = - rootPost && moderationOpts - ? moderatePost(rootPost, moderationOpts) - : undefined - return !!mod - ?.ui('contentList') - .blurs.find( - cause => - cause.type === 'label' && - cause.labelDef.identifier === '!no-unauthenticated', - ) - }, [rootPost, moderationOpts]) - - // Values used for proper rendering of parents - const ref = useRef<ListMethods>(null) - const highlightedPostRef = useRef<View | null>(null) - const [maxParents, setMaxParents] = React.useState( - isWeb ? Infinity : PARENTS_CHUNK_SIZE, - ) - const [maxReplies, setMaxReplies] = React.useState(50) - - useSetTitle( - rootPost && !isNoPwi - ? `${sanitizeDisplayName( - rootPost.author.displayName || `@${rootPost.author.handle}`, - )}: "${rootPostRecord!.text}"` - : '', - ) - - // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. - // This ensures that the first render contains no parents--even if they are already available in the cache. - // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. - // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. - const [deferParents, setDeferParents] = React.useState(isNative) - - const currentDid = currentAccount?.did - const threadModerationCache = React.useMemo(() => { - const cache: ThreadModerationCache = new WeakMap() - if (thread && moderationOpts) { - fillThreadModerationCache(cache, thread, moderationOpts) - } - return cache - }, [thread, moderationOpts]) - - const [justPostedUris, setJustPostedUris] = React.useState( - () => new Set<string>(), - ) - - const [fetchedAtCache] = React.useState(() => new Map<string, number>()) - const [randomCache] = React.useState(() => new Map<string, number>()) - const skeleton = React.useMemo(() => { - if (!thread) return null - return createThreadSkeleton( - sortThread( - thread, - { - // Prefer local state as the source of truth. - sort: sortReplies, - lab_treeViewEnabled: treeViewEnabled, - prioritizeFollowedUsers, - }, - threadModerationCache, - currentDid, - justPostedUris, - threadgateHiddenReplies, - fetchedAtCache, - fetchedAt, - randomCache, - ), - currentDid, - treeView, - threadModerationCache, - hiddenRepliesState !== HiddenRepliesState.Hide, - threadgateHiddenReplies, - ) - }, [ - thread, - prioritizeFollowedUsers, - sortReplies, - treeViewEnabled, - currentDid, - treeView, - threadModerationCache, - hiddenRepliesState, - justPostedUris, - threadgateHiddenReplies, - fetchedAtCache, - fetchedAt, - randomCache, - ]) - - const error = React.useMemo(() => { - if (AppBskyFeedDefs.isNotFoundPost(thread)) { - return { - title: _(msg`Post not found`), - message: _(msg`The post may have been deleted.`), - } - } else if (skeleton?.highlightedPost.type === 'blocked') { - return { - title: _(msg`Post hidden`), - message: _( - msg`You have blocked the author or you have been blocked by the author.`, - ), - } - } else if (threadError?.message.startsWith('Post not found')) { - return { - title: _(msg`Post not found`), - message: _(msg`The post may have been deleted.`), - } - } else if (isThreadError) { - return { - message: threadError ? cleanError(threadError) : undefined, - } - } - - return null - }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) - - // construct content - const posts = React.useMemo(() => { - if (!skeleton) return [] - - const {parents, highlightedPost, replies} = skeleton - let arr: RowItem[] = [] - if (highlightedPost.type === 'post') { - // We want to wait for parents to load before rendering. - // If you add something here, you'll need to update both - // maintainVisibleContentPosition and onContentSizeChange - // to "hold onto" the correct row instead of the first one. - - /* - * This is basically `!!parents.length`, see notes on `isParentLoading` - */ - if (!highlightedPost.ctx.isParentLoading && !deferParents) { - // When progressively revealing parents, rendering a placeholder - // here will cause scrolling jumps. Don't add it unless you test it. - // QT'ing this thread is a great way to test all the scrolling hacks: - // https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o - - // Everything is loaded - let startIndex = Math.max(0, parents.length - maxParents) - for (let i = startIndex; i < parents.length; i++) { - arr.push(parents[i]) - } - } - arr.push(highlightedPost) - if (!highlightedPost.post.viewer?.replyDisabled) { - arr.push(REPLY_PROMPT) - } - for (let i = 0; i < replies.length; i++) { - arr.push(replies[i]) - if (i === maxReplies) { - break - } - } - } - return arr - }, [skeleton, deferParents, maxParents, maxReplies]) - - // This is only used on the web to keep the post in view when its parents load. - // On native, we rely on `maintainVisibleContentPosition` instead. - const didAdjustScrollWeb = useRef<boolean>(false) - const onContentSizeChangeWeb = React.useCallback(() => { - // only run once - if (didAdjustScrollWeb.current) { - return - } - // wait for loading to finish - if (thread?.type === 'post' && !!thread.parent) { - // Measure synchronously to avoid a layout jump. - const postNode = highlightedPostRef.current - const headerNode = headerRef.current - if (postNode && headerNode) { - let pageY = (postNode as any as Element).getBoundingClientRect().top - pageY -= (headerNode as any as Element).getBoundingClientRect().height - pageY = Math.max(0, pageY) - ref.current?.scrollToOffset({ - animated: false, - offset: pageY, - }) - } - didAdjustScrollWeb.current = true - } - }, [thread]) - - // On native, we reveal parents in chunks. Although they're all already - // loaded and FlatList already has its own virtualization, unfortunately FlatList - // has a bug that causes the content to jump around if too many items are getting - // prepended at once. It also jumps around if items get prepended during scroll. - // To work around this, we prepend rows after scroll bumps against the top and rests. - const needsBumpMaxParents = React.useRef(false) - const onStartReached = React.useCallback(() => { - if (skeleton?.parents && maxParents < skeleton.parents.length) { - needsBumpMaxParents.current = true - } - }, [maxParents, skeleton?.parents]) - const bumpMaxParentsIfNeeded = React.useCallback(() => { - if (!isNative) { - return - } - if (needsBumpMaxParents.current) { - needsBumpMaxParents.current = false - setMaxParents(n => n + PARENTS_CHUNK_SIZE) - } - }, []) - const onScrollToTop = bumpMaxParentsIfNeeded - const onMomentumEnd = React.useCallback(() => { - 'worklet' - runOnJS(bumpMaxParentsIfNeeded)() - }, [bumpMaxParentsIfNeeded]) - - const onEndReached = React.useCallback(() => { - if (isFetching || posts.length < maxReplies) return - setMaxReplies(prev => prev + 50) - }, [isFetching, maxReplies, posts.length]) - - const onPostReply = React.useCallback( - (postUri: string | undefined) => { - refetch() - if (postUri) { - setJustPostedUris(set => { - const nextSet = new Set(set) - nextSet.add(postUri) - return nextSet - }) - } - }, - [refetch], - ) - - const {openComposer} = useOpenComposer() - const onReplyToAnchor = React.useCallback(() => { - if (thread?.type !== 'post') { - return - } - if (anchorPostSource) { - feedFeedback.sendInteraction({ - item: thread.post.uri, - event: 'app.bsky.feed.defs#interactionReply', - feedContext: anchorPostSource.post.feedContext, - reqId: anchorPostSource.post.reqId, - }) - } - openComposer({ - replyTo: { - uri: thread.post.uri, - cid: thread.post.cid, - text: thread.record.text, - author: thread.post.author, - embed: thread.post.embed, - moderation: threadModerationCache.get(thread), - langs: thread.record.langs, - }, - onPost: onPostReply, - }) - }, [ - openComposer, - thread, - onPostReply, - threadModerationCache, - anchorPostSource, - feedFeedback, - ]) - - const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled - const hasParents = - skeleton?.highlightedPost?.type === 'post' && - (skeleton.highlightedPost.ctx.isParentLoading || - Boolean(skeleton?.parents && skeleton.parents.length > 0)) - - const renderItem = ({item, index}: {item: RowItem; index: number}) => { - if (item === REPLY_PROMPT && hasSession) { - return ( - <View> - {!isMobile && ( - <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> - )} - </View> - ) - } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { - return ( - <PostThreadShowHiddenReplies - type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} - onPress={() => - setHiddenRepliesState( - item === SHOW_HIDDEN_REPLIES - ? HiddenRepliesState.Show - : HiddenRepliesState.ShowAndOverridePostHider, - ) - } - hideTopBorder={index === 0} - /> - ) - } else if (isThreadNotFound(item)) { - return ( - <View - style={[ - a.p_lg, - index !== 0 && a.border_t, - t.atoms.border_contrast_low, - t.atoms.bg_contrast_25, - ]}> - <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> - <Trans>Deleted post.</Trans> - </Text> - </View> - ) - } else if (isThreadBlocked(item)) { - return ( - <View - style={[ - a.p_lg, - index !== 0 && a.border_t, - t.atoms.border_contrast_low, - t.atoms.bg_contrast_25, - ]}> - <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> - <Trans>Blocked post.</Trans> - </Text> - </View> - ) - } else if (isThreadPost(item)) { - const prev = isThreadPost(posts[index - 1]) - ? (posts[index - 1] as ThreadPost) - : undefined - const next = isThreadPost(posts[index + 1]) - ? (posts[index + 1] as ThreadPost) - : undefined - const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth - const showParentReplyLine = - (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 - const hasUnrevealedParents = - index === 0 && skeleton?.parents && maxParents < skeleton.parents.length - - if (!treeView && prev && item.ctx.hasMoreSelfThread) { - return <PostThreadLoadMore post={prev.post} /> - } - - return ( - <View - ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} - onLayout={deferParents ? () => setDeferParents(false) : undefined}> - <PostThreadItem - post={item.post} - record={item.record} - threadgateRecord={threadgateRecord ?? undefined} - moderation={threadModerationCache.get(item)} - treeView={treeView} - depth={item.ctx.depth} - prevPost={prev} - nextPost={next} - isHighlightedPost={item.ctx.isHighlightedPost} - hasMore={item.ctx.hasMore} - showChildReplyLine={showChildReplyLine} - showParentReplyLine={showParentReplyLine} - hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents} - overrideBlur={ - hiddenRepliesState === - HiddenRepliesState.ShowAndOverridePostHider && - item.ctx.depth > 0 - } - onPostReply={onPostReply} - hideTopBorder={index === 0 && !item.ctx.isParentLoading} - anchorPostSource={anchorPostSource} - /> - </View> - ) - } - return null - } - - if (!thread || !preferences || error) { - return ( - <ListMaybePlaceholder - isLoading={!error} - isError={Boolean(error)} - noEmpty - onRetry={refetch} - errorTitle={error?.title} - errorMessage={error?.message} - /> - ) - } - - return ( - <> - <Header.Outer headerRef={headerRef}> - <Header.BackButton /> - <Header.Content> - <Header.TitleText> - <Trans context="description">Post</Trans> - </Header.TitleText> - </Header.Content> - <Header.Slot> - <ThreadMenu - sortReplies={sortReplies} - treeViewEnabled={treeViewEnabled} - setSortReplies={updateSortReplies} - setTreeViewEnabled={updateTreeViewEnabled} - /> - </Header.Slot> - </Header.Outer> - - <ScrollProvider onMomentumEnd={onMomentumEnd}> - <List - ref={ref} - data={posts} - renderItem={renderItem} - keyExtractor={keyExtractor} - onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} - onStartReached={onStartReached} - onEndReached={onEndReached} - onEndReachedThreshold={2} - onScrollToTop={onScrollToTop} - /** - * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition - */ - maintainVisibleContentPosition={ - isNative && hasParents - ? MAINTAIN_VISIBLE_CONTENT_POSITION - : undefined - } - desktopFixedHeight - removeClippedSubviews={isAndroid ? false : undefined} - ListFooterComponent={ - <ListFooter - // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on - // initial render - isFetchingNextPage={isFetching} - error={cleanError(threadError)} - onRetry={refetch} - // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to - // work without causing weird jumps on web or glitches on native - height={windowHeight - 200} - /> - } - initialNumToRender={initialNumToRender} - windowSize={11} - sideBorders={false} - /> - </ScrollProvider> - {isMobile && canReply && hasSession && ( - <MobileComposePrompt onPressReply={onReplyToAnchor} /> - )} - </> - ) -} - -let ThreadMenu = ({ - sortReplies, - treeViewEnabled, - setSortReplies, - setTreeViewEnabled, -}: { - sortReplies: string - treeViewEnabled: boolean - setSortReplies: (newValue: string) => void - setTreeViewEnabled: (newValue: boolean) => void -}): React.ReactNode => { - const {_} = useLingui() - return ( - <Menu.Root> - <Menu.Trigger label={_(msg`Thread options`)}> - {({props}) => ( - <Button - label={_(msg`Thread options`)} - size="small" - variant="ghost" - color="secondary" - shape="round" - hitSlop={HITSLOP_10} - {...props}> - <ButtonIcon icon={SettingsSlider} size="md" /> - </Button> - )} - </Menu.Trigger> - <Menu.Outer> - <Menu.LabelText> - <Trans>Show replies as</Trans> - </Menu.LabelText> - <Menu.Group> - <Menu.Item - label={_(msg`Linear`)} - onPress={() => { - setTreeViewEnabled(false) - }}> - <Menu.ItemText> - <Trans>Linear</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={!treeViewEnabled} /> - </Menu.Item> - <Menu.Item - label={_(msg`Threaded`)} - onPress={() => { - setTreeViewEnabled(true) - }}> - <Menu.ItemText> - <Trans>Threaded</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={treeViewEnabled} /> - </Menu.Item> - </Menu.Group> - <Menu.Divider /> - <Menu.LabelText> - <Trans>Reply sorting</Trans> - </Menu.LabelText> - <Menu.Group> - <Menu.Item - label={_(msg`Hot replies first`)} - onPress={() => { - setSortReplies('hotness') - }}> - <Menu.ItemText> - <Trans>Hot replies first</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'hotness'} /> - </Menu.Item> - <Menu.Item - label={_(msg`Oldest replies first`)} - onPress={() => { - setSortReplies('oldest') - }}> - <Menu.ItemText> - <Trans>Oldest replies first</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'oldest'} /> - </Menu.Item> - <Menu.Item - label={_(msg`Newest replies first`)} - onPress={() => { - setSortReplies('newest') - }}> - <Menu.ItemText> - <Trans>Newest replies first</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'newest'} /> - </Menu.Item> - <Menu.Item - label={_(msg`Most-liked replies first`)} - onPress={() => { - setSortReplies('most-likes') - }}> - <Menu.ItemText> - <Trans>Most-liked replies first</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'most-likes'} /> - </Menu.Item> - <Menu.Item - label={_(msg`Random (aka "Poster's Roulette")`)} - onPress={() => { - setSortReplies('random') - }}> - <Menu.ItemText> - <Trans>Random (aka "Poster's Roulette")</Trans> - </Menu.ItemText> - <Menu.ItemRadio selected={sortReplies === 'random'} /> - </Menu.Item> - </Menu.Group> - </Menu.Outer> - </Menu.Root> - ) -} -ThreadMenu = memo(ThreadMenu) - -function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { - const {footerHeight} = useShellLayout() - - const animatedStyle = useAnimatedStyle(() => { - return { - bottom: footerHeight.get(), - } - }) - - return ( - <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> - <PostThreadComposePrompt onPressCompose={onPressReply} /> - </Animated.View> - ) -} - -function isThreadPost(v: unknown): v is ThreadPost { - return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' -} - -function isThreadNotFound(v: unknown): v is ThreadNotFound { - return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' -} - -function isThreadBlocked(v: unknown): v is ThreadBlocked { - return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' -} - -function createThreadSkeleton( - node: ThreadNode, - currentDid: string | undefined, - treeView: boolean, - modCache: ThreadModerationCache, - showHiddenReplies: boolean, - threadgateRecordHiddenReplies: Set<string>, -): ThreadSkeletonParts | null { - if (!node) return null - - return { - parents: Array.from(flattenThreadParents(node, !!currentDid)), - highlightedPost: node, - replies: Array.from( - flattenThreadReplies( - node, - currentDid, - treeView, - modCache, - showHiddenReplies, - threadgateRecordHiddenReplies, - ), - ), - } -} - -function* flattenThreadParents( - node: ThreadNode, - hasSession: boolean, -): Generator<YieldedItem, void> { - if (node.type === 'post') { - if (node.parent) { - yield* flattenThreadParents(node.parent, hasSession) - } - if (!node.ctx.isHighlightedPost) { - yield node - } - } else if (node.type === 'not-found') { - yield node - } else if (node.type === 'blocked') { - yield node - } -} - -// The enum is ordered to make them easy to merge -enum HiddenReplyType { - None = 0, - Muted = 1, - Hidden = 2, -} - -function* flattenThreadReplies( - node: ThreadNode, - currentDid: string | undefined, - treeView: boolean, - modCache: ThreadModerationCache, - showHiddenReplies: boolean, - threadgateRecordHiddenReplies: Set<string>, -): Generator<YieldedItem, HiddenReplyType> { - if (node.type === 'post') { - // dont show pwi-opted-out posts to logged out users - if (!currentDid && hasPwiOptOut(node)) { - return HiddenReplyType.None - } - - // handle blurred items - if (node.ctx.depth > 0) { - const modui = modCache.get(node)?.ui('contentList') - if (modui?.blur || modui?.filter) { - if (!showHiddenReplies || node.ctx.depth > 1) { - if ((modui.blurs[0] || modui.filters[0]).type === 'muted') { - return HiddenReplyType.Muted - } - return HiddenReplyType.Hidden - } - } - - if (!showHiddenReplies) { - const hiddenByThreadgate = threadgateRecordHiddenReplies.has( - node.post.uri, - ) - const authorIsViewer = node.post.author.did === currentDid - if (hiddenByThreadgate && !authorIsViewer) { - return HiddenReplyType.Hidden - } - } - } - - if (!node.ctx.isHighlightedPost) { - yield node - } - - if (node.replies?.length) { - let hiddenReplies = HiddenReplyType.None - for (const reply of node.replies) { - let hiddenReply = yield* flattenThreadReplies( - reply, - currentDid, - treeView, - modCache, - showHiddenReplies, - threadgateRecordHiddenReplies, - ) - if (hiddenReply > hiddenReplies) { - hiddenReplies = hiddenReply - } - if (!treeView && !node.ctx.isHighlightedPost) { - break - } - } - - // show control to enable hidden replies - if (node.ctx.depth === 0) { - if (hiddenReplies === HiddenReplyType.Muted) { - yield SHOW_MUTED_REPLIES - } else if (hiddenReplies === HiddenReplyType.Hidden) { - yield SHOW_HIDDEN_REPLIES - } - } - } - } else if (node.type === 'not-found') { - yield node - } else if (node.type === 'blocked') { - yield node - } - return HiddenReplyType.None -} - -function hasPwiOptOut(node: ThreadPost) { - return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') -} - -function hasBranchingReplies(node?: ThreadNode) { - if (!node) { - return false - } - if (node.type !== 'post') { - return false - } - if (!node.replies) { - return false - } - if (node.replies.length === 1) { - return hasBranchingReplies(node.replies[0]) - } - return true -} diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx deleted file mode 100644 index dc0561725..000000000 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import {type StyleProp, View, type ViewStyle} from 'react-native' -import {LinearGradient} from 'expo-linear-gradient' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {PressableScale} from '#/lib/custom-animations/PressableScale' -import {useHaptics} from '#/lib/haptics' -import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' -import {useProfileQuery} from '#/state/queries/profile' -import {useSession} from '#/state/session' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf' -import {transparentifyColor} from '#/alf/util/colorGeneration' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import {Text} from '#/components/Typography' - -export function PostThreadComposePrompt({ - onPressCompose, - style, -}: { - onPressCompose: () => void - style?: StyleProp<ViewStyle> -}) { - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const {_} = useLingui() - const {gtMobile} = useBreakpoints() - const t = useTheme() - const playHaptic = useHaptics() - const { - state: hovered, - onIn: onHoverIn, - onOut: onHoverOut, - } = useInteractionState() - - useHideBottomBarBorderForScreen() - - return ( - <View - style={[ - a.px_sm, - gtMobile - ? [a.py_xs, a.border_t, t.atoms.border_contrast_low, t.atoms.bg] - : [a.pb_2xs], - style, - ]}> - {!gtMobile && ( - <LinearGradient - key={t.name} // android does not update when you change the colors. sigh. - start={[0.5, 0]} - end={[0.5, 1]} - colors={[ - transparentifyColor(t.atoms.bg.backgroundColor, 0), - t.atoms.bg.backgroundColor, - ]} - locations={[0.15, 0.4]} - style={[a.absolute, a.inset_0]} - /> - )} - <PressableScale - accessibilityRole="button" - accessibilityLabel={_(msg`Compose reply`)} - accessibilityHint={_(msg`Opens composer`)} - onPress={() => { - onPressCompose() - playHaptic('Light') - }} - onLongPress={ios(() => { - onPressCompose() - playHaptic('Heavy') - })} - onHoverIn={onHoverIn} - onHoverOut={onHoverOut} - style={[ - a.flex_row, - a.align_center, - a.p_sm, - a.gap_sm, - a.rounded_full, - (!gtMobile || hovered) && t.atoms.bg_contrast_25, - native([a.border, t.atoms.border_contrast_low]), - a.transition_color, - ]}> - <UserAvatar - size={24} - avatar={profile?.avatar} - type={profile?.associated?.labeler ? 'labeler' : 'user'} - /> - <Text style={[a.text_md, t.atoms.text_contrast_medium]}> - <Trans>Write your reply</Trans> - </Text> - </PressableScale> - </View> - ) -} diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx deleted file mode 100644 index fc9296cad..000000000 --- a/src/view/com/post-thread/PostThreadFollowBtn.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react' -import {type AppBskyActorDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' - -import {logger} from '#/logger' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import { - useProfileFollowMutationQueue, - useProfileQuery, -} from '#/state/queries/profile' -import {useRequireAuth} from '#/state/session' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useBreakpoints} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' - -export function PostThreadFollowBtn({did}: {did: string}) { - const {data: profile, isLoading} = useProfileQuery({did}) - - // We will never hit this - the profile will always be cached or loaded above - // but it keeps the typechecker happy - if (isLoading || !profile) return null - - return <PostThreadFollowBtnLoaded profile={profile} /> -} - -function PostThreadFollowBtnLoaded({ - profile: profileUnshadowed, -}: { - profile: AppBskyActorDefs.ProfileViewDetailed -}) { - const navigation = useNavigation() - const {_} = useLingui() - const {gtMobile} = useBreakpoints() - const profile = useProfileShadow(profileUnshadowed) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( - profile, - 'PostThreadItem', - ) - const requireAuth = useRequireAuth() - - const isFollowing = !!profile.viewer?.following - const isFollowedBy = !!profile.viewer?.followedBy - const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing) - - // This prevents the button from disappearing as soon as we follow. - const showFollowBtn = React.useMemo( - () => !isFollowing || !wasFollowing, - [isFollowing, wasFollowing], - ) - - /** - * We want this button to stay visible even after following, so that the user can unfollow if they want. - * However, we need it to disappear after we push to a screen and then come back. We also need it to - * show up if we view the post while following, go to the profile and unfollow, then come back to the - * post. - * - * We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native, - * we could do this only on focus because the transition animation gives us time to not notice the - * sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the - * button renders. So, we update the state in both cases. - */ - React.useEffect(() => { - const updateWasFollowing = () => { - if (wasFollowing !== isFollowing) { - setWasFollowing(isFollowing) - } - } - - const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing) - const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing) - - return () => { - unsubscribeFocus() - unsubscribeBlur() - } - }, [isFollowing, wasFollowing, navigation]) - - const onPress = React.useCallback(() => { - if (!isFollowing) { - requireAuth(async () => { - try { - await queueFollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to follow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') - } - } - }) - } else { - requireAuth(async () => { - try { - await queueUnfollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unfollow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') - } - } - }) - } - }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow]) - - if (!showFollowBtn) return null - - return ( - <Button - testID="followBtn" - label={_(msg`Follow ${profile.handle}`)} - onPress={onPress} - size="small" - variant="solid" - color={isFollowing ? 'secondary' : 'secondary_inverted'} - style={[a.rounded_full]}> - {gtMobile && ( - <ButtonIcon - icon={isFollowing ? Check : Plus} - position="left" - size="sm" - /> - )} - <ButtonText> - {!isFollowing ? ( - isFollowedBy ? ( - <Trans>Follow back</Trans> - ) : ( - <Trans>Follow</Trans> - ) - ) : ( - <Trans>Following</Trans> - )} - </ButtonText> - </Button> - ) -} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx deleted file mode 100644 index 679a506b9..000000000 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ /dev/null @@ -1,1036 +0,0 @@ -import {memo, useCallback, useMemo, useState} from 'react' -import { - type GestureResponderEvent, - StyleSheet, - Text as RNText, - View, -} from 'react-native' -import { - AppBskyFeedDefs, - AppBskyFeedPost, - type AppBskyFeedThreadgate, - AtUri, - type ModerationDecision, - RichText as RichTextAPI, -} from '@atproto/api' -import {msg, Plural, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useActorStatus} from '#/lib/actor-status' -import {MAX_POST_LINES} from '#/lib/constants' -import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {usePalette} from '#/lib/hooks/usePalette' -import {useTranslate} from '#/lib/hooks/useTranslate' -import {makeProfileLink} from '#/lib/routes/links' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' -import {countLines} from '#/lib/strings/helpers' -import {niceDate} from '#/lib/strings/time' -import {s} from '#/lib/styles' -import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' -import {logger} from '#/logger' -import { - POST_TOMBSTONE, - type Shadow, - usePostShadow, -} from '#/state/cache/post-shadow' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' -import {useLanguagePrefs} from '#/state/preferences' -import {type ThreadPost} from '#/state/queries/post-thread' -import {useSession} from '#/state/session' -import {type OnPostSuccessData} from '#/state/shell/composer' -import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {type PostSource} from '#/state/unstable-post-source' -import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' -import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' -import {Link} from '#/view/com/util/Link' -import {formatCount} from '#/view/com/util/numeric/format' -import {PostMeta} from '#/view/com/util/PostMeta' -import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useTheme} from '#/alf' -import {colors} from '#/components/Admonition' -import {Button} from '#/components/Button' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' -import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' -import {InlineLinkText} from '#/components/Link' -import {ContentHider} from '#/components/moderation/ContentHider' -import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' -import {PostAlerts} from '#/components/moderation/PostAlerts' -import {PostHider} from '#/components/moderation/PostHider' -import {type AppModerationCause} from '#/components/Pills' -import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' -import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' -import {PostControls} from '#/components/PostControls' -import * as Prompt from '#/components/Prompt' -import {RichText} from '#/components/RichText' -import {SubtleWebHover} from '#/components/SubtleWebHover' -import {Text} from '#/components/Typography' -import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' -import {WhoCanReply} from '#/components/WhoCanReply' -import * as bsky from '#/types/bsky' - -export function PostThreadItem({ - post, - record, - moderation, - treeView, - depth, - prevPost, - nextPost, - isHighlightedPost, - hasMore, - showChildReplyLine, - showParentReplyLine, - hasPrecedingItem, - overrideBlur, - onPostReply, - onPostSuccess, - hideTopBorder, - threadgateRecord, - anchorPostSource, -}: { - post: AppBskyFeedDefs.PostView - record: AppBskyFeedPost.Record - moderation: ModerationDecision | undefined - treeView: boolean - depth: number - prevPost: ThreadPost | undefined - nextPost: ThreadPost | undefined - isHighlightedPost?: boolean - hasMore?: boolean - showChildReplyLine?: boolean - showParentReplyLine?: boolean - hasPrecedingItem: boolean - overrideBlur: boolean - onPostReply: (postUri: string | undefined) => void - onPostSuccess?: (data: OnPostSuccessData) => void - hideTopBorder?: boolean - threadgateRecord?: AppBskyFeedThreadgate.Record - anchorPostSource?: PostSource -}) { - const postShadowed = usePostShadow(post) - const richText = useMemo( - () => - new RichTextAPI({ - text: record.text, - facets: record.facets, - }), - [record], - ) - if (postShadowed === POST_TOMBSTONE) { - return <PostThreadItemDeleted hideTopBorder={hideTopBorder} /> - } - if (richText && moderation) { - return ( - <PostThreadItemLoaded - // Safeguard from clobbering per-post state below: - key={postShadowed.uri} - post={postShadowed} - prevPost={prevPost} - nextPost={nextPost} - record={record} - richText={richText} - moderation={moderation} - treeView={treeView} - depth={depth} - isHighlightedPost={isHighlightedPost} - hasMore={hasMore} - showChildReplyLine={showChildReplyLine} - showParentReplyLine={showParentReplyLine} - hasPrecedingItem={hasPrecedingItem} - overrideBlur={overrideBlur} - onPostReply={onPostReply} - onPostSuccess={onPostSuccess} - hideTopBorder={hideTopBorder} - threadgateRecord={threadgateRecord} - anchorPostSource={anchorPostSource} - /> - ) - } - return null -} - -function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) { - const t = useTheme() - return ( - <View - style={[ - t.atoms.bg, - t.atoms.border_contrast_low, - a.p_xl, - a.pl_lg, - a.flex_row, - a.gap_md, - !hideTopBorder && a.border_t, - ]}> - <TrashIcon style={[t.atoms.text]} /> - <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}> - <Trans>This post has been deleted.</Trans> - </Text> - </View> - ) -} - -let PostThreadItemLoaded = ({ - post, - record, - richText, - moderation, - treeView, - depth, - prevPost, - nextPost, - isHighlightedPost, - hasMore, - showChildReplyLine, - showParentReplyLine, - hasPrecedingItem, - overrideBlur, - onPostReply, - onPostSuccess, - hideTopBorder, - threadgateRecord, - anchorPostSource, -}: { - post: Shadow<AppBskyFeedDefs.PostView> - record: AppBskyFeedPost.Record - richText: RichTextAPI - moderation: ModerationDecision - treeView: boolean - depth: number - prevPost: ThreadPost | undefined - nextPost: ThreadPost | undefined - isHighlightedPost?: boolean - hasMore?: boolean - showChildReplyLine?: boolean - showParentReplyLine?: boolean - hasPrecedingItem: boolean - overrideBlur: boolean - onPostReply: (postUri: string | undefined) => void - onPostSuccess?: (data: OnPostSuccessData) => void - hideTopBorder?: boolean - threadgateRecord?: AppBskyFeedThreadgate.Record - anchorPostSource?: PostSource -}): React.ReactNode => { - const {currentAccount, hasSession} = useSession() - const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) - - const t = useTheme() - const pal = usePalette('default') - const {_, i18n} = useLingui() - const langPrefs = useLanguagePrefs() - const {openComposer} = useOpenComposer() - const [limitLines, setLimitLines] = useState( - () => countLines(richText?.text) >= MAX_POST_LINES, - ) - const shadowedPostAuthor = useProfileShadow(post.author) - const rootUri = record.reply?.root?.uri || post.uri - const postHref = useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey) - }, [post.uri, post.author]) - const itemTitle = _(msg`Post by ${post.author.handle}`) - const authorHref = makeProfileLink(post.author) - const authorTitle = post.author.handle - const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did - const likesHref = useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') - }, [post.uri, post.author]) - const likesTitle = _(msg`Likes on this post`) - const repostsHref = useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') - }, [post.uri, post.author]) - const repostsTitle = _(msg`Reposts of this post`) - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ - threadgateRecord, - }) - const additionalPostAlerts: AppModerationCause[] = useMemo(() => { - const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) - const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did - return isControlledByViewer && isPostHiddenByThreadgate - ? [ - { - type: 'reply-hidden', - source: {type: 'user', did: currentAccount?.did}, - priority: 6, - }, - ] - : [] - }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri]) - const quotesHref = useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') - }, [post.uri, post.author]) - const quotesTitle = _(msg`Quotes of this post`) - const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( - rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', - ) - const showFollowButton = - currentAccount?.did !== post.author.did && !onlyFollowersCanReply - - const needsTranslation = useMemo( - () => - Boolean( - langPrefs.primaryLanguage && - !isPostInLanguage(post, [langPrefs.primaryLanguage]), - ), - [post, langPrefs.primaryLanguage], - ) - - const onPressReply = () => { - if (anchorPostSource && isHighlightedPost) { - feedFeedback.sendInteraction({ - item: post.uri, - event: 'app.bsky.feed.defs#interactionReply', - feedContext: anchorPostSource.post.feedContext, - reqId: anchorPostSource.post.reqId, - }) - } - openComposer({ - replyTo: { - uri: post.uri, - cid: post.cid, - text: record.text, - author: post.author, - embed: post.embed, - moderation, - langs: record.langs, - }, - onPost: onPostReply, - onPostSuccess: onPostSuccess, - }) - } - - const onOpenAuthor = () => { - if (anchorPostSource) { - feedFeedback.sendInteraction({ - item: post.uri, - event: 'app.bsky.feed.defs#clickthroughAuthor', - feedContext: anchorPostSource.post.feedContext, - reqId: anchorPostSource.post.reqId, - }) - } - } - - const onOpenEmbed = () => { - if (anchorPostSource) { - feedFeedback.sendInteraction({ - item: post.uri, - event: 'app.bsky.feed.defs#clickthroughEmbed', - feedContext: anchorPostSource.post.feedContext, - reqId: anchorPostSource.post.reqId, - }) - } - } - - const onPressShowMore = useCallback(() => { - setLimitLines(false) - }, [setLimitLines]) - - const {isActive: live} = useActorStatus(post.author) - - const reason = anchorPostSource?.post.reason - const viaRepost = useMemo(() => { - if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { - return { - uri: reason.uri, - cid: reason.cid, - } - } - }, [reason]) - - if (!record) { - return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> - } - - if (isHighlightedPost) { - return ( - <> - {rootUri !== post.uri && ( - <View - style={[ - a.pl_lg, - a.flex_row, - a.pb_xs, - {height: a.pt_lg.paddingTop}, - ]}> - <View style={{width: 42}}> - <View - style={[ - styles.replyLine, - a.flex_grow, - {backgroundColor: pal.colors.replyLine}, - ]} - /> - </View> - </View> - )} - - <View - testID={`postThreadItem-by-${post.author.handle}`} - style={[ - a.px_lg, - t.atoms.border_contrast_low, - // root post styles - rootUri === post.uri && [a.pt_lg], - ]}> - <View style={[a.flex_row, a.gap_md, a.pb_md]}> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - live={live} - onBeforePress={onOpenAuthor} - /> - <View style={[a.flex_1]}> - <View style={[a.flex_row, a.align_center]}> - <Link - style={[a.flex_shrink]} - href={authorHref} - title={authorTitle} - onBeforePress={onOpenAuthor}> - <Text - emoji - style={[ - a.text_lg, - a.font_bold, - a.leading_snug, - a.self_start, - ]} - numberOfLines={1}> - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} - </Text> - </Link> - - <View style={[{paddingLeft: 3, top: -1}]}> - <VerificationCheckButton - profile={shadowedPostAuthor} - size="md" - /> - </View> - </View> - <Link style={s.flex1} href={authorHref} title={authorTitle}> - <Text - emoji - style={[ - a.text_md, - a.leading_snug, - t.atoms.text_contrast_medium, - ]} - numberOfLines={1}> - {sanitizeHandle(post.author.handle, '@')} - </Text> - </Link> - </View> - {showFollowButton && ( - <View> - <PostThreadFollowBtn did={post.author.did} /> - </View> - )} - </View> - <View style={[a.pb_sm]}> - <LabelsOnMyPost post={post} style={[a.pb_sm]} /> - <ContentHider - modui={moderation.ui('contentView')} - ignoreMute - childContainerStyle={[a.pt_sm]}> - <PostAlerts - modui={moderation.ui('contentView')} - size="lg" - includeMute - style={[a.pb_sm]} - additionalCauses={additionalPostAlerts} - /> - {richText?.text ? ( - <RichText - enableTags - selectable - value={richText} - style={[a.flex_1, a.text_xl]} - authorHandle={post.author.handle} - shouldProxyLinks={true} - /> - ) : undefined} - {post.embed && ( - <View style={[a.py_xs]}> - <Embed - embed={post.embed} - moderation={moderation} - viewContext={PostEmbedViewContext.ThreadHighlighted} - onOpen={onOpenEmbed} - /> - </View> - )} - </ContentHider> - <ExpandedPostDetails - post={post} - record={record} - isThreadAuthor={isThreadAuthor} - needsTranslation={needsTranslation} - /> - {post.repostCount !== 0 || - post.likeCount !== 0 || - post.quoteCount !== 0 ? ( - // Show this section unless we're *sure* it has no engagement. - <View - style={[ - a.flex_row, - a.align_center, - a.gap_lg, - a.border_t, - a.border_b, - a.mt_md, - a.py_md, - t.atoms.border_contrast_low, - ]}> - {post.repostCount != null && post.repostCount !== 0 ? ( - <Link href={repostsHref} title={repostsTitle}> - <Text - testID="repostCount-expanded" - style={[a.text_md, t.atoms.text_contrast_medium]}> - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {formatCount(i18n, post.repostCount)} - </Text>{' '} - <Plural - value={post.repostCount} - one="repost" - other="reposts" - /> - </Text> - </Link> - ) : null} - {post.quoteCount != null && - post.quoteCount !== 0 && - !post.viewer?.embeddingDisabled ? ( - <Link href={quotesHref} title={quotesTitle}> - <Text - testID="quoteCount-expanded" - style={[a.text_md, t.atoms.text_contrast_medium]}> - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {formatCount(i18n, post.quoteCount)} - </Text>{' '} - <Plural - value={post.quoteCount} - one="quote" - other="quotes" - /> - </Text> - </Link> - ) : null} - {post.likeCount != null && post.likeCount !== 0 ? ( - <Link href={likesHref} title={likesTitle}> - <Text - testID="likeCount-expanded" - style={[a.text_md, t.atoms.text_contrast_medium]}> - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {formatCount(i18n, post.likeCount)} - </Text>{' '} - <Plural value={post.likeCount} one="like" other="likes" /> - </Text> - </Link> - ) : null} - </View> - ) : null} - <View - style={[ - a.pt_sm, - a.pb_2xs, - { - marginLeft: -5, - }, - ]}> - <FeedFeedbackProvider value={feedFeedback}> - <PostControls - big - post={post} - record={record} - richText={richText} - onPressReply={onPressReply} - onPostReply={onPostReply} - logContext="PostThreadItem" - threadgateRecord={threadgateRecord} - feedContext={anchorPostSource?.post?.feedContext} - reqId={anchorPostSource?.post?.reqId} - viaRepost={viaRepost} - /> - </FeedFeedbackProvider> - </View> - </View> - </View> - </> - ) - } else { - const isThreadedChild = treeView && depth > 0 - const isThreadedChildAdjacentTop = - isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1 - const isThreadedChildAdjacentBot = - isThreadedChild && nextPost?.ctx.depth === depth - return ( - <PostOuterWrapper - post={post} - depth={depth} - showParentReplyLine={!!showParentReplyLine} - treeView={treeView} - hasPrecedingItem={hasPrecedingItem} - hideTopBorder={hideTopBorder}> - <PostHider - testID={`postThreadItem-by-${post.author.handle}`} - href={postHref} - disabled={overrideBlur} - modui={moderation.ui('contentList')} - iconSize={isThreadedChild ? 24 : 42} - iconStyles={ - isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2} - } - profile={post.author} - interpretFilterAsBlur> - <View - style={{ - flexDirection: 'row', - gap: 10, - paddingLeft: 8, - height: isThreadedChildAdjacentTop ? 8 : 16, - }}> - <View style={{width: 42}}> - {!isThreadedChild && showParentReplyLine && ( - <View - style={[ - styles.replyLine, - { - flexGrow: 1, - backgroundColor: pal.colors.replyLine, - marginBottom: 4, - }, - ]} - /> - )} - </View> - </View> - - <View - style={[ - a.flex_row, - a.px_sm, - a.gap_md, - { - paddingBottom: - showChildReplyLine && !isThreadedChild - ? 0 - : isThreadedChildAdjacentBot - ? 4 - : 8, - }, - ]}> - {/* If we are in threaded mode, the avatar is rendered in PostMeta */} - {!isThreadedChild && ( - <View> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - live={live} - /> - - {showChildReplyLine && ( - <View - style={[ - styles.replyLine, - { - flexGrow: 1, - backgroundColor: pal.colors.replyLine, - marginTop: 4, - }, - ]} - /> - )} - </View> - )} - - <View style={[a.flex_1]}> - <PostMeta - author={post.author} - moderation={moderation} - timestamp={post.indexedAt} - postHref={postHref} - showAvatar={isThreadedChild} - avatarSize={24} - style={[a.pb_xs]} - /> - <LabelsOnMyPost post={post} style={[a.pb_xs]} /> - <PostAlerts - modui={moderation.ui('contentList')} - style={[a.pb_2xs]} - additionalCauses={additionalPostAlerts} - /> - {richText?.text ? ( - <View style={[a.pb_2xs, a.pr_sm]}> - <RichText - enableTags - value={richText} - style={[a.flex_1, a.text_md]} - numberOfLines={limitLines ? MAX_POST_LINES : undefined} - authorHandle={post.author.handle} - shouldProxyLinks={true} - /> - {limitLines && ( - <ShowMoreTextButton - style={[a.text_md]} - onPress={onPressShowMore} - /> - )} - </View> - ) : undefined} - {post.embed && ( - <View style={[a.pb_xs]}> - <Embed - embed={post.embed} - moderation={moderation} - viewContext={PostEmbedViewContext.Feed} - /> - </View> - )} - <PostControls - post={post} - record={record} - richText={richText} - onPressReply={onPressReply} - logContext="PostThreadItem" - threadgateRecord={threadgateRecord} - /> - </View> - </View> - {hasMore ? ( - <Link - style={[ - styles.loadMore, - { - paddingLeft: treeView ? 8 : 70, - paddingTop: 0, - paddingBottom: treeView ? 4 : 12, - }, - ]} - href={postHref} - title={itemTitle} - noFeedback> - <Text - style={[t.atoms.text_contrast_medium, a.font_bold, a.text_sm]}> - <Trans>More</Trans> - </Text> - <ChevronRightIcon - size="xs" - style={[t.atoms.text_contrast_medium]} - /> - </Link> - ) : undefined} - </PostHider> - </PostOuterWrapper> - ) - } -} -PostThreadItemLoaded = memo(PostThreadItemLoaded) - -function PostOuterWrapper({ - post, - treeView, - depth, - showParentReplyLine, - hasPrecedingItem, - hideTopBorder, - children, -}: React.PropsWithChildren<{ - post: AppBskyFeedDefs.PostView - treeView: boolean - depth: number - showParentReplyLine: boolean - hasPrecedingItem: boolean - hideTopBorder?: boolean -}>) { - const t = useTheme() - const { - state: hover, - onIn: onHoverIn, - onOut: onHoverOut, - } = useInteractionState() - if (treeView && depth > 0) { - return ( - <View - style={[ - a.flex_row, - a.px_sm, - a.flex_row, - t.atoms.border_contrast_low, - styles.cursor, - depth === 1 && a.border_t, - ]} - onPointerEnter={onHoverIn} - onPointerLeave={onHoverOut}> - {Array.from(Array(depth - 1)).map((_, n: number) => ( - <View - key={`${post.uri}-padding-${n}`} - style={[ - a.ml_sm, - t.atoms.border_contrast_low, - { - borderLeftWidth: 2, - paddingLeft: a.pl_sm.paddingLeft - 2, // minus border - }, - ]} - /> - ))} - <View style={a.flex_1}> - <SubtleWebHover - hover={hover} - style={{ - left: (depth === 1 ? 0 : 2) - a.pl_sm.paddingLeft, - right: -a.pr_sm.paddingRight, - }} - /> - {children} - </View> - </View> - ) - } - return ( - <View - onPointerEnter={onHoverIn} - onPointerLeave={onHoverOut} - style={[ - a.border_t, - a.px_sm, - t.atoms.border_contrast_low, - showParentReplyLine && hasPrecedingItem && styles.noTopBorder, - hideTopBorder && styles.noTopBorder, - styles.cursor, - ]}> - <SubtleWebHover hover={hover} /> - {children} - </View> - ) -} - -function ExpandedPostDetails({ - post, - record, - isThreadAuthor, - needsTranslation, -}: { - post: AppBskyFeedDefs.PostView - record: AppBskyFeedPost.Record - isThreadAuthor: boolean - needsTranslation: boolean -}) { - const t = useTheme() - const pal = usePalette('default') - const {_, i18n} = useLingui() - const translate = useTranslate() - const isRootPost = !('reply' in post.record) - const langPrefs = useLanguagePrefs() - - const onTranslatePress = useCallback( - (e: GestureResponderEvent) => { - e.preventDefault() - translate(record.text || '', langPrefs.primaryLanguage) - - if ( - bsky.dangerousIsType<AppBskyFeedPost.Record>( - post.record, - AppBskyFeedPost.isRecord, - ) - ) { - logger.metric( - 'translate', - { - sourceLanguages: post.record.langs ?? [], - targetLanguage: langPrefs.primaryLanguage, - textLength: post.record.text.length, - }, - {statsig: false}, - ) - } - - return false - }, - [translate, record.text, langPrefs, post], - ) - - return ( - <View style={[a.gap_md, a.pt_md, a.align_start]}> - <BackdatedPostIndicator post={post} /> - <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> - {niceDate(i18n, post.indexedAt)} - </Text> - {isRootPost && ( - <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> - )} - {needsTranslation && ( - <> - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> - · - </Text> - - <InlineLinkText - // overridden to open an intent on android, but keep - // as anchor tag for accessibility - to={getTranslatorLink(record.text, langPrefs.primaryLanguage)} - label={_(msg`Translate`)} - style={[a.text_sm, pal.link]} - onPress={onTranslatePress}> - <Trans>Translate</Trans> - </InlineLinkText> - </> - )} - </View> - </View> - ) -} - -function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { - const t = useTheme() - const {_, i18n} = useLingui() - const control = Prompt.usePromptControl() - - const indexedAt = new Date(post.indexedAt) - const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>( - post.record, - AppBskyFeedPost.isRecord, - ) - ? new Date(post.record.createdAt) - : new Date(post.indexedAt) - - // backdated if createdAt is 24 hours or more before indexedAt - const isBackdated = - indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 - - if (!isBackdated) return null - - const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light - - return ( - <> - <Button - label={_(msg`Archived post`)} - accessibilityHint={_( - msg`Shows information about when this post was created`, - )} - onPress={e => { - e.preventDefault() - e.stopPropagation() - control.open() - }}> - {({hovered, pressed}) => ( - <View - style={[ - a.flex_row, - a.align_center, - a.rounded_full, - t.atoms.bg_contrast_25, - (hovered || pressed) && t.atoms.bg_contrast_50, - { - gap: 3, - paddingHorizontal: 6, - paddingVertical: 3, - }, - ]}> - <CalendarClockIcon fill={orange} size="sm" aria-hidden /> - <Text - style={[ - a.text_xs, - a.font_bold, - a.leading_tight, - t.atoms.text_contrast_medium, - ]}> - <Trans>Archived from {niceDate(i18n, createdAt)}</Trans> - </Text> - </View> - )} - </Button> - - <Prompt.Outer control={control}> - <Prompt.TitleText> - <Trans>Archived post</Trans> - </Prompt.TitleText> - <Prompt.DescriptionText> - <Trans> - This post claims to have been created on{' '} - <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>, - but was first seen by Bluesky on{' '} - <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>. - </Trans> - </Prompt.DescriptionText> - <Text - style={[ - a.text_md, - a.leading_snug, - t.atoms.text_contrast_high, - a.pb_xl, - ]}> - <Trans> - Bluesky cannot confirm the authenticity of the claimed date. - </Trans> - </Text> - <Prompt.Actions> - <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> - </Prompt.Actions> - </Prompt.Outer> - </> - ) -} - -function getThreadAuthor( - post: AppBskyFeedDefs.PostView, - record: AppBskyFeedPost.Record, -): string { - if (!record.reply) { - return post.author.did - } - try { - return new AtUri(record.reply.root.uri).host - } catch { - return '' - } -} - -const styles = StyleSheet.create({ - outer: { - borderTopWidth: StyleSheet.hairlineWidth, - paddingLeft: 8, - }, - noTopBorder: { - borderTopWidth: 0, - }, - meta: { - flexDirection: 'row', - paddingVertical: 2, - }, - metaExpandedLine1: { - paddingVertical: 0, - }, - loadMore: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - gap: 4, - paddingHorizontal: 20, - }, - replyLine: { - width: 2, - marginLeft: 'auto', - marginRight: 'auto', - }, - cursor: { - // @ts-ignore web only - cursor: 'pointer', - }, -}) diff --git a/src/view/com/post-thread/PostThreadLoadMore.tsx b/src/view/com/post-thread/PostThreadLoadMore.tsx deleted file mode 100644 index 27e2ea724..000000000 --- a/src/view/com/post-thread/PostThreadLoadMore.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from 'react' -import {View} from 'react-native' -import {AppBskyFeedDefs, AtUri} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {makeProfileLink} from '#/lib/routes/links' -import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' -import {Link} from '../util/Link' -import {UserAvatar} from '../util/UserAvatar' - -export function PostThreadLoadMore({post}: {post: AppBskyFeedDefs.PostView}) { - const t = useTheme() - - const postHref = React.useMemo(() => { - const urip = new AtUri(post.uri) - return makeProfileLink(post.author, 'post', urip.rkey) - }, [post.uri, post.author]) - - return ( - <Link - href={postHref} - style={[a.flex_row, a.align_center, a.py_md, {paddingHorizontal: 14}]} - hoverStyle={[t.atoms.bg_contrast_25]}> - <View style={[a.flex_row]}> - <View - style={{ - alignItems: 'center', - justifyContent: 'center', - width: 34, - height: 34, - borderRadius: 18, - backgroundColor: t.atoms.bg.backgroundColor, - marginRight: -20, - }}> - <UserAvatar - avatar={post.author.avatar} - size={30} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - /> - </View> - <View - style={{ - alignItems: 'center', - justifyContent: 'center', - width: 34, - height: 34, - borderRadius: 18, - backgroundColor: t.atoms.bg.backgroundColor, - }}> - <UserAvatar - avatar={post.author.avatar} - size={30} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - /> - </View> - </View> - <View style={[a.px_sm]}> - <Text style={[{color: t.palette.primary_500}, a.text_md]}> - <Trans>Continue thread...</Trans> - </Text> - </View> - </Link> - ) -} diff --git a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx b/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx deleted file mode 100644 index 7dc75520b..000000000 --- a/src/view/com/post-thread/PostThreadShowHiddenReplies.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' -import {Text} from '#/components/Typography' - -export function PostThreadShowHiddenReplies({ - type, - onPress, - hideTopBorder, -}: { - type: 'hidden' | 'muted' - onPress: () => void - hideTopBorder?: boolean -}) { - const {_} = useLingui() - const t = useTheme() - const label = - type === 'muted' ? _(msg`Show muted replies`) : _(msg`Show hidden replies`) - - return ( - <Button onPress={onPress} label={label}> - {({hovered, pressed}) => ( - <View - style={[ - a.flex_1, - a.flex_row, - a.align_center, - a.gap_sm, - a.py_lg, - a.px_xl, - !hideTopBorder && a.border_t, - t.atoms.border_contrast_low, - hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg, - ]}> - <View - style={[ - t.atoms.bg_contrast_25, - a.align_center, - a.justify_center, - { - width: 26, - height: 26, - borderRadius: 13, - marginRight: 4, - }, - ]}> - <EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} /> - </View> - <Text - style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]} - numberOfLines={1}> - {label} - </Text> - </View> - )} - </Button> - ) -} diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index c2780a2a5..2f03a168b 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -176,7 +176,7 @@ let FeedItemInner = ({ const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey) }, [post.uri, post.author]) - const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() + const {sendInteraction, feedSourceInfo} = useFeedFeedbackContext() const onPressReply = () => { sendInteraction({ @@ -234,7 +234,7 @@ let FeedItemInner = ({ }) unstableCacheProfileView(queryClient, post.author) setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { - feed: feedDescriptor, + feedSourceInfo, post: { post, reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined, diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 879bf22f9..df8b2e481 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -27,6 +27,7 @@ import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' +import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' @@ -45,6 +46,7 @@ import { } from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {StarterPack} from '#/components/icons/StarterPack' import {EditLiveDialog} from '#/components/live/EditLiveDialog' import {GoLiveDialog} from '#/components/live/GoLiveDialog' import * as Menu from '#/components/Menu' @@ -88,6 +90,7 @@ let ProfileMenu = ({ const blockPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl() const goLiveDialogControl = useDialogControl() + const addToStarterPacksDialogControl = useDialogControl() const showLoggedOutWarning = React.useMemo(() => { return ( @@ -301,6 +304,15 @@ let ProfileMenu = ({ </> )} <Menu.Item + testID="profileHeaderDropdownStarterPackAddRemoveBtn" + label={_(msg`Add to starter packs`)} + onPress={addToStarterPacksDialogControl.open}> + <Menu.ItemText> + <Trans>Add to starter packs</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={StarterPack} /> + </Menu.Item> + <Menu.Item testID="profileHeaderDropdownListAddRemoveBtn" label={_(msg`Add to lists`)} onPress={onPressAddRemoveLists}> @@ -440,6 +452,11 @@ let ProfileMenu = ({ </Menu.Outer> </Menu.Root> + <StarterPackDialog + control={addToStarterPacksDialogControl} + targetDid={profile.did} + /> + <ReportDialog control={reportDialogControl} subject={{ diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index 3273cf195..8e39e28c0 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -1,8 +1,10 @@ -import {LogBox, Pressable, View} from 'react-native' +import {useState} from 'react' +import {LogBox, Pressable, View, TextInput} from 'react-native' import {useQueryClient} from '@tanstack/react-query' +import {setBlueskyProxyHeader} from '#/lib/constants' import {useModalControls} from '#/state/modals' -import {useSessionApi} from '#/state/session' +import {useSessionApi, useAgent} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useOnboardingDispatch} from '#/state/shell/onboarding' import {navigate} from '../../../Navigation' @@ -18,6 +20,7 @@ LogBox.ignoreAllLogs() const BTN = {height: 1, width: 1, backgroundColor: 'red'} export function TestCtrls() { + const agent = useAgent() const queryClient = useQueryClient() const {logoutEveryAccount, login} = useSessionApi() const {openModal} = useModalControls() @@ -45,8 +48,19 @@ export function TestCtrls() { ) setShowLoggedOut(false) } + const [proxyHeader, setProxyHeader] = useState('') return ( <View style={{position: 'absolute', top: 100, right: 0, zIndex: 100}}> + <TextInput + testID="e2eProxyHeaderInput" + onChangeText={val => setProxyHeader(val as any)} + onSubmitEditing={() => { + const header = `${proxyHeader}#bsky_appview` + setBlueskyProxyHeader(header as any) + agent.configureProxy(header as any) + }} + style={BTN} + /> <Pressable testID="e2eSignInAlice" onPress={onPressSignInAlice} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx deleted file mode 100644 index e20dadb49..000000000 --- a/src/view/com/util/forms/DropdownButton.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import {type PropsWithChildren} from 'react' -import {useMemo, useRef} from 'react' -import { - Dimensions, - type GestureResponderEvent, - type Insets, - type StyleProp, - StyleSheet, - TouchableOpacity, - TouchableWithoutFeedback, - useWindowDimensions, - View, - type ViewStyle, -} from 'react-native' -import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated' -import RootSiblings from 'react-native-root-siblings' -import {type IconProp} from '@fortawesome/fontawesome-svg-core' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import type React from 'react' - -import {HITSLOP_10} from '#/lib/constants' -import {usePalette} from '#/lib/hooks/usePalette' -import {colors} from '#/lib/styles' -import {useTheme} from '#/lib/ThemeContext' -import {isWeb} from '#/platform/detection' -import {native} from '#/alf' -import {FullWindowOverlay} from '#/components/FullWindowOverlay' -import {Text} from '../text/Text' -import {Button, type ButtonType} from './Button' - -const ESTIMATED_BTN_HEIGHT = 50 -const ESTIMATED_SEP_HEIGHT = 16 -const ESTIMATED_HEADING_HEIGHT = 60 - -export interface DropdownItemButton { - testID?: string - icon?: IconProp - label: string - onPress: () => void -} -export interface DropdownItemSeparator { - sep: true -} -export interface DropdownItemHeading { - heading: true - label: string -} -export type DropdownItem = - | DropdownItemButton - | DropdownItemSeparator - | DropdownItemHeading -type MaybeDropdownItem = DropdownItem | false | undefined - -export type DropdownButtonType = ButtonType | 'bare' - -interface DropdownButtonProps { - testID?: string - type?: DropdownButtonType - style?: StyleProp<ViewStyle> - items: MaybeDropdownItem[] - label?: string - menuWidth?: number - children?: React.ReactNode - openToRight?: boolean - openUpwards?: boolean - rightOffset?: number - bottomOffset?: number - hitSlop?: Insets - accessibilityLabel?: string - accessibilityHint?: string -} - -/** - * @deprecated use Menu from `#/components/Menu.tsx` instead - */ -export function DropdownButton({ - testID, - type = 'bare', - style, - items, - label, - menuWidth, - children, - openToRight = false, - openUpwards = false, - rightOffset = 0, - bottomOffset = 0, - hitSlop = HITSLOP_10, - accessibilityLabel, -}: PropsWithChildren<DropdownButtonProps>) { - const {_} = useLingui() - - const ref1 = useRef<View>(null) - const ref2 = useRef<View>(null) - - const onPress = (e: GestureResponderEvent) => { - const ref = ref1.current || ref2.current - const {height: winHeight} = Dimensions.get('window') - const pressY = e.nativeEvent.pageY - ref?.measure( - ( - _x: number, - _y: number, - width: number, - _height: number, - pageX: number, - pageY: number, - ) => { - if (!menuWidth) { - menuWidth = 200 - } - let estimatedMenuHeight = 0 - for (const item of items) { - if (item && isSep(item)) { - estimatedMenuHeight += ESTIMATED_SEP_HEIGHT - } else if (item && isBtn(item)) { - estimatedMenuHeight += ESTIMATED_BTN_HEIGHT - } else if (item && isHeading(item)) { - estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT - } - } - const newX = openToRight - ? pageX + width + rightOffset - : pageX + width - menuWidth - - // Add a bit of additional room - let newY = pressY + bottomOffset + 20 - if (openUpwards || newY + estimatedMenuHeight > winHeight) { - newY -= estimatedMenuHeight - } - createDropdownMenu( - newX, - newY, - pageY, - menuWidth, - items.filter(v => !!v) as DropdownItem[], - openUpwards, - ) - }, - ) - } - - const numItems = useMemo( - () => - items.filter(item => { - if (item === undefined || item === false) { - return false - } - - return isBtn(item) - }).length, - [items], - ) - - if (type === 'bare') { - return ( - <TouchableOpacity - testID={testID} - style={style} - onPress={onPress} - hitSlop={hitSlop} - ref={ref1} - accessibilityRole="button" - accessibilityLabel={ - accessibilityLabel || _(msg`Opens ${numItems} options`) - } - accessibilityHint=""> - {children} - </TouchableOpacity> - ) - } - return ( - <View ref={ref2}> - <Button - type={type} - testID={testID} - onPress={onPress} - style={style} - label={label}> - {children} - </Button> - </View> - ) -} - -function createDropdownMenu( - x: number, - y: number, - pageY: number, - width: number, - items: DropdownItem[], - opensUpwards = false, -): RootSiblings { - const onPressItem = (index: number) => { - sibling.destroy() - const item = items[index] - if (isBtn(item)) { - item.onPress() - } - } - const onOuterPress = () => sibling.destroy() - const sibling = new RootSiblings( - ( - <DropdownItems - onOuterPress={onOuterPress} - x={x} - y={y} - pageY={pageY} - width={width} - items={items} - onPressItem={onPressItem} - openUpwards={opensUpwards} - /> - ), - ) - return sibling -} - -type DropDownItemProps = { - onOuterPress: () => void - x: number - y: number - pageY: number - width: number - items: DropdownItem[] - onPressItem: (index: number) => void - openUpwards: boolean -} - -const DropdownItems = ({ - onOuterPress, - x, - y, - pageY, - width, - items, - onPressItem, - openUpwards, -}: DropDownItemProps) => { - const pal = usePalette('default') - const theme = useTheme() - const {_} = useLingui() - const {height: screenHeight} = useWindowDimensions() - const dropDownBackgroundColor = - theme.colorScheme === 'dark' ? pal.btn : pal.view - const separatorColor = - theme.colorScheme === 'dark' ? pal.borderDark : pal.border - - const numItems = items.filter(isBtn).length - - // TODO: Refactor dropdown components to: - // - (On web, if not handled by React Native) use semantic <select /> - // and <option /> elements for keyboard navigation out of the box - // - (On mobile) be buttons by default, accept `label` and `nativeID` - // props, and always have an explicit label - return ( - <FullWindowOverlay> - {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */} - <TouchableWithoutFeedback - onPress={onOuterPress} - accessibilityLabel={_(msg`Toggle dropdown`)} - accessibilityHint=""> - <Animated.View - entering={FadeIn} - style={[ - styles.bg, - // On web we need to adjust the top and bottom relative to the scroll position - isWeb - ? { - top: -pageY, - bottom: pageY - screenHeight, - } - : { - top: 0, - bottom: 0, - }, - ]} - /> - </TouchableWithoutFeedback> - <Animated.View - entering={native( - openUpwards ? FadeInDown.springify(1000) : FadeInUp.springify(1000), - )} - style={[ - styles.menu, - {left: x, top: y, width}, - dropDownBackgroundColor, - ]}> - {items.map((item, index) => { - if (isBtn(item)) { - return ( - <TouchableOpacity - testID={item.testID} - key={index} - style={[styles.menuItem]} - onPress={() => onPressItem(index)} - accessibilityRole="button" - accessibilityLabel={item.label} - accessibilityHint={_( - msg`Selects option ${index + 1} of ${numItems}`, - )}> - {item.icon && ( - <FontAwesomeIcon - style={styles.icon} - icon={item.icon} - color={pal.text.color as string} - /> - )} - <Text style={[styles.label, pal.text]}>{item.label}</Text> - </TouchableOpacity> - ) - } else if (isSep(item)) { - return ( - <View key={index} style={[styles.separator, separatorColor]} /> - ) - } else if (isHeading(item)) { - return ( - <View style={[styles.heading, pal.border]} key={index}> - <Text style={[pal.text, styles.headingLabel]}> - {item.label} - </Text> - </View> - ) - } - return null - })} - </Animated.View> - </FullWindowOverlay> - ) -} - -function isSep(item: DropdownItem): item is DropdownItemSeparator { - return 'sep' in item && item.sep -} -function isHeading(item: DropdownItem): item is DropdownItemHeading { - return 'heading' in item && item.heading -} -function isBtn(item: DropdownItem): item is DropdownItemButton { - return !isSep(item) && !isHeading(item) -} - -const styles = StyleSheet.create({ - bg: { - position: 'absolute', - left: 0, - width: '100%', - backgroundColor: 'rgba(0, 0, 0, 0.1)', - }, - menu: { - position: 'absolute', - backgroundColor: '#fff', - borderRadius: 14, - paddingVertical: 6, - }, - menuItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 10, - paddingLeft: 15, - paddingRight: 40, - }, - menuItemBorder: { - borderTopWidth: 1, - borderTopColor: colors.gray1, - marginTop: 4, - paddingTop: 12, - }, - icon: { - marginLeft: 2, - marginRight: 8, - flexShrink: 0, - }, - label: { - fontSize: 18, - flexShrink: 1, - flexGrow: 1, - }, - separator: { - borderTopWidth: 1, - marginVertical: 8, - }, - heading: { - flexDirection: 'row', - justifyContent: 'center', - paddingVertical: 10, - paddingLeft: 15, - paddingRight: 20, - borderBottomWidth: 1, - marginBottom: 6, - }, - headingLabel: { - fontSize: 18, - fontWeight: '600', - }, -}) diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx index 1a236f8bc..8b81cee10 100644 --- a/src/view/screens/Debug.tsx +++ b/src/view/screens/Debug.tsx @@ -4,17 +4,16 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {usePalette} from '#/lib/hooks/usePalette' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import {s} from '#/lib/styles' -import {PaletteColorName, ThemeProvider} from '#/lib/ThemeContext' +import {type PaletteColorName, ThemeProvider} from '#/lib/ThemeContext' import {EmptyState} from '#/view/com/util/EmptyState' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {Button} from '#/view/com/util/forms/Button' -import { - DropdownButton, - DropdownItem, -} from '#/view/com/util/forms/DropdownButton' import {ToggleButton} from '#/view/com/util/forms/ToggleButton' import * as LoadingPlaceholder from '#/view/com/util/LoadingPlaceholder' import {Text} from '#/view/com/util/text/Text' @@ -134,8 +133,6 @@ function ControlsView() { <ScrollView style={[s.pl10, s.pr10]}> <Heading label="Buttons" /> <ButtonsView /> - <Heading label="Dropdown Buttons" /> - <DropdownButtonsView /> <Heading label="Toggle Buttons" /> <ToggleButtonsView /> <View style={s.footerSpacer} /> @@ -396,44 +393,6 @@ function ButtonsView() { ) } -const DROPDOWN_ITEMS: DropdownItem[] = [ - { - icon: ['far', 'paste'], - label: 'Copy post text', - onPress() {}, - }, - { - icon: 'share', - label: 'Share...', - onPress() {}, - }, - { - icon: 'circle-exclamation', - label: 'Report post', - onPress() {}, - }, -] -function DropdownButtonsView() { - const defaultPal = usePalette('default') - return ( - <View style={[defaultPal.view]}> - <View style={s.mb5}> - <DropdownButton - type="primary" - items={DROPDOWN_ITEMS} - menuWidth={200} - label="Primary button" - /> - </View> - <View style={s.mb5}> - <DropdownButton type="bare" items={DROPDOWN_ITEMS} menuWidth={200}> - <Text>Bare</Text> - </DropdownButton> - </View> - </View> - ) -} - function ToggleButtonsView() { const defaultPal = usePalette('default') const buttonStyles = s.mb5 diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx index d2e492f7e..f2afe8235 100644 --- a/src/view/screens/DebugMod.tsx +++ b/src/view/screens/DebugMod.tsx @@ -31,8 +31,11 @@ import { groupNotifications, shouldFilterNotif, } from '#/state/queries/notifications/util' +import {threadPost} from '#/state/queries/usePostThread/views' import {useSession} from '#/state/session' import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {ThreadItemAnchor} from '#/screens/PostThread/components/ThreadItemAnchor' +import {ThreadItemPost} from '#/screens/PostThread/components/ThreadItemPost' import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -49,7 +52,6 @@ import * as ProfileCard from '#/components/ProfileCard' import {H1, H3, P, Text} from '#/components/Typography' import {ScreenHider} from '../../components/moderation/ScreenHider' import {NotificationFeedItem} from '../com/notifications/NotificationFeedItem' -import {PostThreadItem} from '../com/post-thread/PostThreadItem' import {PostFeedItem} from '../com/posts/PostFeedItem' const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys( @@ -519,13 +521,13 @@ export const DebugModScreen = ({}: NativeStackScreenProps< <MockPostFeedItem post={post} moderation={postModeration} /> <Heading title="Post" subtitle="viewed directly" /> - <MockPostThreadItem post={post} moderation={postModeration} /> + <MockPostThreadItem post={post} moderationOpts={modOpts} /> <Heading title="Post" subtitle="reply in thread" /> <MockPostThreadItem post={post} - moderation={postModeration} - reply + moderationOpts={modOpts} + isReply /> </> )} @@ -837,28 +839,33 @@ function MockPostFeedItem({ function MockPostThreadItem({ post, - moderation, - reply, + moderationOpts, + isReply, }: { post: AppBskyFeedDefs.PostView - moderation: ModerationDecision - reply?: boolean + moderationOpts: ModerationOpts + isReply?: boolean }) { - return ( - <PostThreadItem - // @ts-ignore - post={post} - record={post.record as AppBskyFeedPost.Record} - moderation={moderation} - depth={reply ? 1 : 0} - isHighlightedPost={!reply} - treeView={false} - prevPost={undefined} - nextPost={undefined} - hasPrecedingItem={false} - overrideBlur={false} - onPostReply={() => {}} - /> + const thread = threadPost({ + uri: post.uri, + depth: isReply ? 1 : 0, + value: { + $type: 'app.bsky.unspecced.defs#threadItemPost', + post, + moreParents: false, + moreReplies: 0, + opThread: false, + hiddenByThreadgate: false, + mutedByViewer: false, + }, + moderationOpts, + threadgateHiddenReplies: new Set<string>(), + }) + + return isReply ? ( + <ThreadItemPost item={thread} /> + ) : ( + <ThreadItemAnchor item={thread} /> ) } diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index cc611e0d6..f07c971fb 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -5,17 +5,14 @@ import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' import {makeRecordUri} from '#/lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' -import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread' import {PostThread} from '#/screens/PostThread' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> export function PostThreadScreen({route}: Props) { const setMinimalShellMode = useSetMinimalShellMode() - const gate = useGate() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) @@ -28,11 +25,7 @@ export function PostThreadScreen({route}: Props) { return ( <Layout.Screen testID="postThreadScreen"> - {gate('post_threads_v2_unspecced') || __DEV__ ? ( - <PostThread uri={uri} /> - ) : ( - <PostThreadComponent uri={uri} /> - )} + <PostThread uri={uri} /> </Layout.Screen> ) } diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx index 98d5b05e3..91fe0d970 100644 --- a/src/view/screens/Storybook/Toasts.tsx +++ b/src/view/screens/Storybook/Toasts.tsx @@ -2,10 +2,58 @@ import {Pressable, View} from 'react-native' import {show as deprecatedShow} from '#/view/com/util/Toast' import {atoms as a} from '#/alf' -import * as toast from '#/components/Toast' -import {Toast} from '#/components/Toast/Toast' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import * as Toast from '#/components/Toast' import {H1} from '#/components/Typography' +function DefaultToast({ + content, + type = 'default', +}: { + content: string + type?: Toast.ToastType +}) { + return ( + <Toast.ToastConfigProvider id="default-toast" type={type}> + <Toast.Outer> + <Toast.Icon icon={GlobeIcon} /> + <Toast.Text>{content}</Toast.Text> + </Toast.Outer> + </Toast.ToastConfigProvider> + ) +} + +function ToastWithAction() { + return ( + <Toast.Outer> + <Toast.Icon icon={GlobeIcon} /> + <Toast.Text>This toast has an action button</Toast.Text> + <Toast.Action + label="Action" + onPress={() => console.log('Action clicked!')}> + Action + </Toast.Action> + </Toast.Outer> + ) +} + +function LongToastWithAction() { + return ( + <Toast.Outer> + <Toast.Icon icon={GlobeIcon} /> + <Toast.Text> + This is a longer message to test how the toast handles multiple lines of + text content. + </Toast.Text> + <Toast.Action + label="Action" + onPress={() => console.log('Action clicked!')}> + Action + </Toast.Action> + </Toast.Outer> + ) +} + export function Toasts() { return ( <View style={[a.gap_md]}> @@ -14,62 +62,77 @@ export function Toasts() { <View style={[a.gap_md]}> <Pressable accessibilityRole="button" - onPress={() => toast.show(`Hey I'm a toast!`)}> - <Toast content="Hey I'm a toast!" /> + onPress={() => Toast.show(<ToastWithAction />, {type: 'success'})}> + <ToastWithAction /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => Toast.show(<ToastWithAction />, {type: 'error'})}> + <ToastWithAction /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => Toast.show(<LongToastWithAction />)}> + <LongToastWithAction /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => Toast.show(`Hey I'm a toast!`)}> + <DefaultToast content="Hey I'm a toast!" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`This toast will disappear after 6 seconds`, { + Toast.show(`This toast will disappear after 6 seconds`, { duration: 6e3, }) }> - <Toast content="This toast will disappear after 6 seconds" /> + <DefaultToast content="This toast will disappear after 6 seconds" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show( + Toast.show( `This is a longer message to test how the toast handles multiple lines of text content.`, ) }> - <Toast content="This is a longer message to test how the toast handles multiple lines of text content." /> + <DefaultToast content="This is a longer message to test how the toast handles multiple lines of text content." /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`Success! Yayyyyyyy :)`, { + Toast.show(`Success! Yayyyyyyy :)`, { type: 'success', }) }> - <Toast content="Success! Yayyyyyyy :)" type="success" /> + <DefaultToast content="Success! Yayyyyyyy :)" type="success" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`I'm providing info!`, { + Toast.show(`I'm providing info!`, { type: 'info', }) }> - <Toast content="I'm providing info!" type="info" /> + <DefaultToast content="I'm providing info!" type="info" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`This is a warning toast`, { + Toast.show(`This is a warning toast`, { type: 'warning', }) }> - <Toast content="This is a warning toast" type="warning" /> + <DefaultToast content="This is a warning toast" type="warning" /> </Pressable> <Pressable accessibilityRole="button" onPress={() => - toast.show(`This is an error toast :(`, { + Toast.show(`This is an error toast :(`, { type: 'error', }) }> - <Toast content="This is an error toast :(" type="error" /> + <DefaultToast content="This is an error toast :(" type="error" /> </Pressable> <Pressable @@ -80,7 +143,7 @@ export function Toasts() { 'exclamation-circle', ) }> - <Toast + <DefaultToast content="This is a test of the deprecated API" type="warning" /> diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 7a56722cc..441b35e3b 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -4,7 +4,7 @@ import {useLingui} from '@lingui/react' import {useNavigation, useNavigationState} from '@react-navigation/native' import {getCurrentRoute} from '#/lib/routes/helpers' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {emitSoftReset} from '#/state/events' import {usePinnedFeedsInfos} from '#/state/queries/feed' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' @@ -30,7 +30,8 @@ export function DesktopFeeds() { <View style={[ { - gap: 12, + gap: 10, + paddingVertical: 2, }, ]}> {Array(5) @@ -66,6 +67,7 @@ export function DesktopFeeds() { * height of the screen with lots of feeds. */ paddingVertical: 2, + marginHorizontal: -2, overflowY: 'auto', }), ]}> @@ -90,6 +92,10 @@ export function DesktopFeeds() { current ? [a.font_bold, t.atoms.text] : [t.atoms.text_contrast_medium], + web({ + marginHorizontal: 2, + width: 'calc(100% - 4px)', + }), ]} numberOfLines={1}> {feedInfo.displayName} @@ -100,7 +106,14 @@ export function DesktopFeeds() { <InlineLinkText to="/feeds" label={_(msg`More feeds`)} - style={[a.text_md, a.leading_snug]} + style={[ + a.text_md, + a.leading_snug, + web({ + marginHorizontal: 2, + width: 'calc(100% - 4px)', + }), + ]} numberOfLines={1}> {_(msg`More feeds`)} </InlineLinkText> diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index aa18f9b70..cf1ff8425 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -236,6 +236,7 @@ function SwitchMenuItems({ setShowLoggedOut(true) closeEverything() } + return ( <Menu.Outer> {accounts && accounts.length > 0 && ( @@ -255,6 +256,7 @@ function SwitchMenuItems({ <Menu.Divider /> </> )} + <SwitcherMenuProfileLink /> <Menu.Item label={_(msg`Add another account`)} onPress={onAddAnotherAccount}> @@ -273,6 +275,56 @@ function SwitchMenuItems({ ) } +function SwitcherMenuProfileLink() { + const {_} = useLingui() + const {currentAccount} = useSession() + const navigation = useNavigation() + const context = Menu.useMenuContext() + const profileLink = currentAccount ? makeProfileLink(currentAccount) : '/' + const [pathName] = useMemo(() => router.matchPath(profileLink), [profileLink]) + const currentRouteInfo = useNavigationState(state => { + if (!state) { + return {name: 'Home'} + } + return getCurrentRoute(state) + }) + let isCurrent = + currentRouteInfo.name === 'Profile' + ? isTab(currentRouteInfo.name, pathName) && + (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === + currentAccount?.handle + : isTab(currentRouteInfo.name, pathName) + const onProfilePress = useCallback( + (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => { + if (e.ctrlKey || e.metaKey || e.altKey) { + return + } + e.preventDefault() + context.control.close() + if (isCurrent) { + emitSoftReset() + } else { + const [screen, params] = router.matchPath(profileLink) + // @ts-expect-error TODO: type matchPath well enough that it can be plugged into navigation.navigate directly + navigation.navigate(screen, params, {pop: true}) + } + }, + [navigation, profileLink, isCurrent, context], + ) + return ( + <Menu.Item + label={_(msg`Go to profile`)} + // @ts-expect-error The function signature differs on web -inb + onPress={onProfilePress} + href={profileLink}> + <Menu.ItemIcon icon={UserCircle} /> + <Menu.ItemText> + <Trans>Go to profile</Trans> + </Menu.ItemText> + </Menu.Item> + ) +} + function SwitchMenuItem({ account, profile, diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 26795e0fd..1e000340a 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -65,6 +65,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) { style={[ gutters, a.gap_lg, + a.pr_2xs, web({ position: 'fixed', left: '50%', @@ -74,7 +75,10 @@ export function DesktopRightNav({routeName}: {routeName: string}) { }, ...a.scrollbar_offset.transform, ], - width: width + gutters.paddingLeft, + /** + * Compensate for the right padding above (2px) to retain intended width. + */ + width: width + gutters.paddingLeft + 2, maxHeight: '100%', overflowY: 'auto', }), diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx index 6b49f5834..c8ef49ee7 100644 --- a/src/view/shell/desktop/SidebarTrendingTopics.tsx +++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx @@ -82,6 +82,7 @@ function Inner() { <TrendingTopicLink key={topic.link} topic={topic} + style={a.rounded_full} onPress={() => { logEvent('trendingTopic:click', {context: 'sidebar'}) }}> |