diff options
Diffstat (limited to 'src/view/com')
50 files changed, 2061 insertions, 1502 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 0fae996ff..ecfef3ecd 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -32,6 +32,8 @@ import {s, colors, gradients} from 'lib/styles' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {cleanError} from 'lib/strings/errors' +import {shortenLinks} from 'lib/strings/rich-text-manip' +import {toShortUrl} from 'lib/strings/url-helpers' import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn' import {usePalette} from 'lib/hooks/usePalette' @@ -41,6 +43,7 @@ import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection' import {GalleryModel} from 'state/models/media/gallery' import {Gallery} from './photos/Gallery' import {MAX_GRAPHEME_LENGTH} from 'lib/constants' +import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' type Props = ComposerOpts & { @@ -62,11 +65,14 @@ export const ComposePost = observer(function ComposePost({ const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') const [richtext, setRichText] = useState(new RichText({text: ''})) - const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext]) + const graphemeLength = useMemo(() => { + return shortenLinks(richtext).graphemeLength + }, [richtext]) const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( initQuote, ) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) + const [labels, setLabels] = useState<string[]>([]) const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const gallery = useMemo(() => new GalleryModel(store), [store]) @@ -145,76 +151,59 @@ export const ComposePost = observer(function ComposePost({ [gallery, track], ) - const onPressPublish = useCallback( - async (rt: RichText) => { - if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) { - return - } - if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { - return - } + const onPressPublish = async () => { + if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { + return + } + if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { + return + } - setError('') + setError('') - if (rt.text.trim().length === 0 && gallery.isEmpty) { - setError('Did you want to say anything?') - return - } + if (richtext.text.trim().length === 0 && gallery.isEmpty) { + setError('Did you want to say anything?') + return + } - setIsProcessing(true) + setIsProcessing(true) - let createdPost - try { - createdPost = await apilib.post(store, { - rawText: rt.text, - replyTo: replyTo?.uri, - images: gallery.images, - quote: quote, - extLink: extLink, - onStateChange: setProcessingState, - knownHandles: autocompleteView.knownHandles, - langs: store.preferences.postLanguages, - }) - } catch (e: any) { - if (extLink) { - setExtLink({ - ...extLink, - isLoading: true, - localThumb: undefined, - } as apilib.ExternalEmbedDraft) - } - setError(cleanError(e.message)) - setIsProcessing(false) - return - } finally { - track('Create Post', { - imageCount: gallery.size, - }) - if (replyTo && replyTo.uri) track('Post:Reply') - } - if (!replyTo) { - await store.me.mainFeed.addPostToTop(createdPost.uri) + try { + await apilib.post(store, { + rawText: richtext.text, + replyTo: replyTo?.uri, + images: gallery.images, + quote, + extLink, + labels, + onStateChange: setProcessingState, + knownHandles: autocompleteView.knownHandles, + langs: store.preferences.postLanguages, + }) + } catch (e: any) { + if (extLink) { + setExtLink({ + ...extLink, + isLoading: true, + localThumb: undefined, + } as apilib.ExternalEmbedDraft) } - onPost?.() - onClose() - Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) - }, - [ - isProcessing, - setError, - setIsProcessing, - replyTo, - autocompleteView.knownHandles, - extLink, - onClose, - onPost, - quote, - setExtLink, - store, - track, - gallery, - ], - ) + setError(cleanError(e.message)) + setIsProcessing(false) + return + } finally { + track('Create Post', { + imageCount: gallery.size, + }) + if (replyTo && replyTo.uri) track('Post:Reply') + } + if (!replyTo) { + store.me.mainFeed.onPostCreated() + } + onPost?.() + onClose() + Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) + } const canPost = useMemo( () => @@ -229,6 +218,7 @@ export const ComposePost = observer(function ComposePost({ const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?` const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size]) + const hasMedia = gallery.size > 0 || Boolean(extLink) return ( <KeyboardAvoidingView @@ -247,6 +237,7 @@ export const ComposePost = observer(function ComposePost({ <Text style={[pal.link, s.f18]}>Cancel</Text> </TouchableOpacity> <View style={s.flex1} /> + <LabelsBtn labels={labels} onChange={setLabels} hasMedia={hasMedia} /> {isProcessing ? ( <View style={styles.postBtn}> <ActivityIndicator /> @@ -254,9 +245,7 @@ export const ComposePost = observer(function ComposePost({ ) : canPost ? ( <TouchableOpacity testID="composerPublishBtn" - onPress={() => { - onPressPublish(richtext) - }} + onPress={onPressPublish} accessibilityRole="button" accessibilityLabel={replyTo ? 'Publish reply' : 'Publish post'} accessibilityHint={ @@ -366,20 +355,23 @@ export const ComposePost = observer(function ComposePost({ </ScrollView> {!extLink && suggestedLinks.size > 0 ? ( <View style={s.mb5}> - {Array.from(suggestedLinks).map(url => ( - <TouchableOpacity - key={`suggested-${url}`} - testID="addLinkCardBtn" - style={[pal.borderDark, styles.addExtLinkBtn]} - onPress={() => onPressAddLinkCard(url)} - accessibilityRole="button" - accessibilityLabel="Add link card" - accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> - <Text style={pal.text}> - Add link card: <Text style={pal.link}>{url}</Text> - </Text> - </TouchableOpacity> - ))} + {Array.from(suggestedLinks) + .slice(0, 3) + .map(url => ( + <TouchableOpacity + key={`suggested-${url}`} + testID="addLinkCardBtn" + style={[pal.borderDark, styles.addExtLinkBtn]} + onPress={() => onPressAddLinkCard(url)} + accessibilityRole="button" + accessibilityLabel="Add link card" + accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> + <Text style={pal.text}> + Add link card:{' '} + <Text style={pal.link}>{toShortUrl(url)}</Text> + </Text> + </TouchableOpacity> + ))} </View> ) : null} <View style={[pal.border, styles.bottomBar]}> @@ -408,7 +400,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', paddingTop: isDesktopWeb ? 10 : undefined, - paddingBottom: 10, + paddingBottom: isDesktopWeb ? 10 : 4, paddingHorizontal: 20, height: 55, }, diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx new file mode 100644 index 000000000..96908d47f --- /dev/null +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import {Keyboard, StyleSheet} from 'react-native' +import {observer} from 'mobx-react-lite' +import {Button} from 'view/com/util/forms/Button' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {ShieldExclamation} from 'lib/icons' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' +import {isNative} from 'platform/detection' + +export const LabelsBtn = observer(function LabelsBtn({ + labels, + hasMedia, + onChange, +}: { + labels: string[] + hasMedia: boolean + onChange: (v: string[]) => void +}) { + const pal = usePalette('default') + const store = useStores() + + return ( + <Button + type="default-light" + testID="labelsBtn" + style={[styles.button, !hasMedia && styles.dimmed]} + accessibilityLabel="Content warnings" + accessibilityHint="" + onPress={() => { + if (isNative) { + if (Keyboard.isVisible()) { + Keyboard.dismiss() + } + } + store.shell.openModal({name: 'self-label', labels, hasMedia, onChange}) + }}> + <ShieldExclamation style={pal.link} size={26} /> + {labels.length > 0 ? ( + <FontAwesomeIcon + icon="check" + size={16} + style={pal.link as FontAwesomeIconStyle} + /> + ) : null} + </Button> + ) +}) + +const styles = StyleSheet.create({ + button: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + marginRight: 4, + }, + dimmed: { + opacity: 0.4, + }, + label: { + maxWidth: 100, + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 245c17b9c..f64880e15 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,6 +1,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {RichText} from '@atproto/api' +import EventEmitter from 'eventemitter3' import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Document} from '@tiptap/extension-document' import History from '@tiptap/extension-history' @@ -53,6 +54,22 @@ export const TextInput = React.forwardRef( 'ProseMirror-dark', ) + // we use a memoized emitter to propagate events out of tiptap + // without triggering re-runs of the useEditor hook + const emitter = React.useMemo(() => new EventEmitter(), []) + React.useEffect(() => { + emitter.addListener('publish', onPressPublish) + return () => { + emitter.removeListener('publish', onPressPublish) + } + }, [emitter, onPressPublish]) + React.useEffect(() => { + emitter.addListener('photo-pasted', onPhotoPasted) + return () => { + emitter.removeListener('photo-pasted', onPhotoPasted) + } + }, [emitter, onPhotoPasted]) + const editor = useEditor( { extensions: [ @@ -60,6 +77,7 @@ export const TextInput = React.forwardRef( Link.configure({ protocols: ['http', 'https'], autolink: true, + linkOnPaste: false, }), Mention.configure({ HTMLAttributes: { @@ -86,16 +104,13 @@ export const TextInput = React.forwardRef( return } - getImageFromUri(items, onPhotoPasted) + getImageFromUri(items, (uri: string) => { + emitter.emit('photo-pasted', uri) + }) }, handleKeyDown: (_, event) => { if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') { - // Workaround relying on previous state from `setRichText` to - // get the updated text content during editor initialization - setRichText((state: RichText) => { - onPressPublish(state) - return state - }) + emitter.emit('publish') } }, }, @@ -107,6 +122,7 @@ export const TextInput = React.forwardRef( const json = editorProp.getJSON() const newRt = new RichText({text: editorJsonToText(json).trim()}) + newRt.detectFacetsWithoutResolution() setRichText(newRt) const newSuggestedLinks = new Set(editorJsonToLinks(json)) @@ -115,7 +131,7 @@ export const TextInput = React.forwardRef( } }, }, - [modeClass], + [modeClass, emitter], ) React.useImperativeHandle(ref, () => ({ diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx index 79f1dd74d..264c2d982 100644 --- a/src/view/com/feeds/CustomFeed.tsx +++ b/src/view/com/feeds/CustomFeed.tsx @@ -69,6 +69,7 @@ export const CustomFeed = observer( return ( <TouchableOpacity + testID={`feed-${item.displayName}`} accessibilityRole="button" style={[styles.container, pal.border, style]} onPress={() => { diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index b900f9afe..f5e858209 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -17,6 +17,7 @@ import { NativeSyntheticEvent, NativeMethodsMixin, } from 'react-native' +import {Image} from 'expo-image' import useImageDimensions from '../../hooks/useImageDimensions' import usePanResponder from '../../hooks/usePanResponder' @@ -41,6 +42,8 @@ type Props = { doubleTapToZoomEnabled?: boolean } +const AnimatedImage = Animated.createAnimatedComponent(Image) + const ImageItem = ({ imageSrc, onZoom, @@ -128,7 +131,7 @@ const ImageItem = ({ onScroll, onScrollEndDrag, })}> - <Animated.Image + <AnimatedImage {...panHandlers} source={imageSrc} style={imageStylesWithOpacity} diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index ebf0b1d28..a6b98009a 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -18,6 +18,7 @@ import { NativeSyntheticEvent, TouchableWithoutFeedback, } from 'react-native' +import {Image} from 'expo-image' import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom' import useImageDimensions from '../../hooks/useImageDimensions' @@ -42,6 +43,8 @@ type Props = { doubleTapToZoomEnabled?: boolean } +const AnimatedImage = Animated.createAnimatedComponent(Image) + const ImageItem = ({ imageSrc, onZoom, @@ -131,7 +134,7 @@ const ImageItem = ({ accessibilityRole="image" accessibilityLabel={imageSrc.alt} accessibilityHint=""> - <Animated.Image + <AnimatedImage source={imageSrc} style={imageStylesWithOpacity} onLoad={() => setLoaded(true)} diff --git a/src/view/com/lists/ListActions.tsx b/src/view/com/lists/ListActions.tsx index ee5a2afcb..353338198 100644 --- a/src/view/com/lists/ListActions.tsx +++ b/src/view/com/lists/ListActions.tsx @@ -11,6 +11,7 @@ export const ListActions = ({ isOwner, onPressDeleteList, onPressShareList, + onPressReportList, reversed = false, // Default value of reversed is false }: { isOwner: boolean @@ -19,6 +20,7 @@ export const ListActions = ({ onPressEditList?: () => void onPressDeleteList?: () => void onPressShareList?: () => void + onPressReportList?: () => void reversed?: boolean // New optional prop }) => { const pal = usePalette('default') @@ -64,6 +66,17 @@ export const ListActions = ({ onPress={onPressShareList}> <FontAwesomeIcon icon={'share'} style={[pal.text]} /> </Button>, + !isOwner && ( + <Button + key="reportListBtn" + testID="reportListBtn" + type="default" + accessibilityLabel="Report list" + accessibilityHint="" + onPress={onPressReportList}> + <FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} /> + </Button> + ), ] // If reversed is true, reverse the array to reverse the order of the buttons diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx index 188518ea5..7f2173d78 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListItems.tsx @@ -45,6 +45,7 @@ export const ListItems = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, renderEmptyState, testID, headerOffset = 0, @@ -57,6 +58,7 @@ export const ListItems = observer( onPressEditList: () => void onPressDeleteList: () => void onPressShareList: () => void + onPressReportList: () => void renderEmptyState?: () => JSX.Element testID?: string headerOffset?: number @@ -169,6 +171,7 @@ export const ListItems = observer( onPressEditList={onPressEditList} onPressDeleteList={onPressDeleteList} onPressShareList={onPressShareList} + onPressReportList={onPressReportList} /> ) : null } else if (item === ERROR_ITEM) { @@ -208,6 +211,7 @@ export const ListItems = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, onPressTryAgain, onPressRetryLoadMore, ], @@ -267,6 +271,7 @@ const ListHeader = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, }: { list: AppBskyGraphDefs.ListView isOwner: boolean @@ -274,6 +279,7 @@ const ListHeader = observer( onPressEditList: () => void onPressDeleteList: () => void onPressShareList: () => void + onPressReportList: () => void }) => { const pal = usePalette('default') const store = useStores() @@ -300,6 +306,7 @@ const ListHeader = observer( <TextLink text={sanitizeHandle(list.creator.handle, '@')} href={makeProfileLink(list.creator)} + style={pal.textLight} /> )} </Text> @@ -319,6 +326,7 @@ const ListHeader = observer( onPressEditList={onPressEditList} onToggleSubscribed={onToggleSubscribed} onPressShareList={onPressShareList} + onPressReportList={onPressReportList} /> )} </View> diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx index 2b6f74c2b..fb07ee0b8 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/ListsList.tsx @@ -1,6 +1,5 @@ import React, {MutableRefObject} from 'react' import { - ActivityIndicator, RefreshControl, StyleProp, StyleSheet, @@ -166,18 +165,6 @@ export const ListsList = observer( ], ) - const Footer = React.useCallback( - () => - listsList.isLoading ? ( - <View style={styles.feedFooter}> - <ActivityIndicator /> - </View> - ) : ( - <View /> - ), - [listsList], - ) - return ( <View testID={testID} style={style}> {data.length > 0 && ( @@ -187,7 +174,6 @@ export const ListsList = observer( data={data} keyExtractor={item => item._reactKey} renderItem={renderItemInner} - ListFooterComponent={Footer} refreshControl={ <RefreshControl refreshing={isRefreshing} diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index a6010906c..0b9707622 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -493,7 +493,9 @@ function CustomHandleForm({ <ActivityIndicator color="white" /> ) : ( <Text type="xl-medium" style={[s.white, s.textCenter]}> - {canSave ? `Update to ${handle}` : 'Verify DNS Record'} + {canSave + ? `Update to ${handle}` + : `Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`} </Text> )} </Button> diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index b3fe9dd3f..d46579f09 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -53,11 +53,7 @@ export function Component({}: {}) { Invite a Friend </Text> <Text type="lg" style={[styles.description, pal.text]}> - Send these invites to your friends so they can create an account. Each - code works once! - </Text> - <Text type="sm" style={[styles.description, pal.textLight]}> - (You'll receive one invite code every two weeks.) + Each code works once. You'll receive more invite codes periodically. </Text> <ScrollView style={[styles.scrollContainer, pal.border]}> {store.me.invites.map((invite, i) => ( diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx index 0f001f911..bfb7e4dc0 100644 --- a/src/view/com/modals/ListAddRemoveUser.tsx +++ b/src/view/com/modals/ListAddRemoveUser.tsx @@ -1,6 +1,6 @@ import React, {useCallback} from 'react' import {observer} from 'mobx-react-lite' -import {Pressable, StyleSheet, View} from 'react-native' +import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import { FontAwesomeIcon, @@ -42,6 +42,7 @@ export const Component = observer( string[] >([]) const [selected, setSelected] = React.useState<string[]>([]) + const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) const listsList: ListsListModel = React.useMemo( () => new ListsListModel(store, store.me.did), @@ -58,12 +59,13 @@ export const Component = observer( const ids = memberships.memberships.map(m => m.value.list) setOriginalSelections(ids) setSelected(ids) + setMembershipsLoaded(true) }, err => { store.log.error('Failed to fetch memberships', {err}) }, ) - }, [memberships, listsList, store, setSelected]) + }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) const onPressCancel = useCallback(() => { store.shell.closeModal() @@ -107,11 +109,16 @@ export const Component = observer( return ( <Pressable testID={`toggleBtn-${list.name}`} - style={[styles.listItem, pal.border]} + style={[ + styles.listItem, + pal.border, + {opacity: membershipsLoaded ? 1 : 0.5}, + ]} accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ list.name }`} accessibilityHint="" + disabled={!membershipsLoaded} onPress={() => onToggleSelected(list.uri)}> <View style={styles.listItemAvi}> <UserAvatar size={40} avatar={list.avatar} /> @@ -132,23 +139,33 @@ export const Component = observer( : sanitizeHandle(list.creator.handle, '@')} </Text> </View> - <View - style={ - isSelected - ? [styles.checkbox, palPrimary.border, palPrimary.view] - : [styles.checkbox, pal.borderDark] - }> - {isSelected && ( - <FontAwesomeIcon - icon="check" - style={palInverted.text as FontAwesomeIconStyle} - /> - )} - </View> + {membershipsLoaded && ( + <View + style={ + isSelected + ? [styles.checkbox, palPrimary.border, palPrimary.view] + : [styles.checkbox, pal.borderDark] + }> + {isSelected && ( + <FontAwesomeIcon + icon="check" + style={palInverted.text as FontAwesomeIconStyle} + /> + )} + </View> + )} </Pressable> ) }, - [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did], + [ + pal, + palPrimary, + palInverted, + onToggleSelected, + selected, + store.me.did, + membershipsLoaded, + ], ) const renderEmptyState = React.useCallback(() => { @@ -200,6 +217,12 @@ export const Component = observer( label="Save Changes" /> )} + + {(listsList.isLoading || !membershipsLoaded) && ( + <View style={styles.loadingContainer}> + <ActivityIndicator /> + </View> + )} </View> </View> ) @@ -221,6 +244,7 @@ const styles = StyleSheet.create({ borderTopWidth: 1, }, btns: { + position: 'relative', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', @@ -263,4 +287,11 @@ const styles = StyleSheet.create({ borderRadius: 6, marginRight: 8, }, + loadingContainer: { + position: 'absolute', + top: 10, + right: 0, + bottom: 0, + justifyContent: 'center', + }, }) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 525df7ba1..efd06412d 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -6,18 +6,20 @@ import BottomSheet from '@gorhom/bottom-sheet' import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' +import {navigate} from '../../../Navigation' +import once from 'lodash.once' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' -import * as ReportPostModal from './report/ReportPost' import * as RepostModal from './Repost' +import * as SelfLabelModal from './SelfLabel' import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' -import * as ReportAccountModal from './report/ReportAccount' +import * as ReportModal from './report/Modal' import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' @@ -28,6 +30,7 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as OnboardingModal from './OnboardingModal' +import * as ModerationDetailsModal from './ModerationDetails' const DEFAULT_SNAPPOINTS = ['90%'] @@ -35,9 +38,25 @@ export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() const bottomSheetRef = useRef<BottomSheet>(null) const pal = usePalette('default') + + const activeModal = + store.shell.activeModals[store.shell.activeModals.length - 1] + + const navigateOnce = once(navigate) + + const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => { + if (activeModal?.name === 'profile-preview' && toIndex === 1) { + // begin loading the profile screen behind the scenes + navigateOnce('Profile', {name: activeModal.did}) + } + } const onBottomSheetChange = (snapPoint: number) => { if (snapPoint === -1) { store.shell.closeModal() + } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) { + // ensure we navigate to Profile and close the modal + navigateOnce('Profile', {name: activeModal.did}) + store.shell.closeModal() } } const onClose = () => { @@ -45,9 +64,6 @@ export const ModalsContainer = observer(function ModalsContainer() { store.shell.closeModal() } - const activeModal = - store.shell.activeModals[store.shell.activeModals.length - 1] - useEffect(() => { if (store.shell.isModalActive) { bottomSheetRef.current?.expand() @@ -70,12 +86,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'server-input') { snapPoints = ServerInputModal.snapPoints element = <ServerInputModal.Component {...activeModal} /> - } else if (activeModal?.name === 'report-post') { - snapPoints = ReportPostModal.snapPoints - element = <ReportPostModal.Component {...activeModal} /> - } else if (activeModal?.name === 'report-account') { - snapPoints = ReportAccountModal.snapPoints - element = <ReportAccountModal.Component {...activeModal} /> + } else if (activeModal?.name === 'report') { + snapPoints = ReportModal.snapPoints + element = <ReportModal.Component {...activeModal} /> } else if (activeModal?.name === 'create-or-edit-mute-list') { snapPoints = CreateOrEditMuteListModal.snapPoints element = <CreateOrEditMuteListModal.Component {...activeModal} /> @@ -88,6 +101,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'repost') { snapPoints = RepostModal.snapPoints element = <RepostModal.Component {...activeModal} /> + } else if (activeModal?.name === 'self-label') { + snapPoints = SelfLabelModal.snapPoints + element = <SelfLabelModal.Component {...activeModal} /> } else if (activeModal?.name === 'alt-text-image') { snapPoints = AltImageModal.snapPoints element = <AltImageModal.Component {...activeModal} /> @@ -121,6 +137,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'onboarding') { snapPoints = OnboardingModal.snapPoints element = <OnboardingModal.Component /> + } else if (activeModal?.name === 'moderation-details') { + snapPoints = ModerationDetailsModal.snapPoints + element = <ModerationDetailsModal.Component {...activeModal} /> } else { return null } @@ -146,6 +165,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } handleIndicatorStyle={{backgroundColor: pal.text.color}} handleStyle={[styles.handle, pal.view]} + onAnimate={onBottomSheetAnimate} onChange={onBottomSheetChange}> {element} </BottomSheet> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 39cdbd868..0e28b1618 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -10,12 +10,12 @@ import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' -import * as ReportPostModal from './report/ReportPost' -import * as ReportAccountModal from './report/ReportAccount' +import * as ReportModal from './report/Modal' import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' +import * as SelfLabelModal from './SelfLabel' import * as CropImageModal from './crop-image/CropImage.web' import * as AltTextImageModal from './AltImage' import * as EditImageModal from './EditImage' @@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as OnboardingModal from './OnboardingModal' +import * as ModerationDetailsModal from './ModerationDetails' import * as PreferencesHomeFeed from './PreferencesHomeFeed' @@ -74,10 +75,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <ProfilePreviewModal.Component {...modal} /> } else if (modal.name === 'server-input') { element = <ServerInputModal.Component {...modal} /> - } else if (modal.name === 'report-post') { - element = <ReportPostModal.Component {...modal} /> - } else if (modal.name === 'report-account') { - element = <ReportAccountModal.Component {...modal} /> + } else if (modal.name === 'report') { + element = <ReportModal.Component {...modal} /> } else if (modal.name === 'create-or-edit-mute-list') { element = <CreateOrEditMuteListModal.Component {...modal} /> } else if (modal.name === 'list-add-remove-user') { @@ -88,6 +87,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <DeleteAccountModal.Component /> } else if (modal.name === 'repost') { element = <RepostModal.Component {...modal} /> + } else if (modal.name === 'self-label') { + element = <SelfLabelModal.Component {...modal} /> } else if (modal.name === 'change-handle') { element = <ChangeHandleModal.Component {...modal} /> } else if (modal.name === 'waitlist') { @@ -110,6 +111,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <PreferencesHomeFeed.Component /> } else if (modal.name === 'onboarding') { element = <OnboardingModal.Component /> + } else if (modal.name === 'moderation-details') { + element = <ModerationDetailsModal.Component {...modal} /> } else { return null } diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx new file mode 100644 index 000000000..b0e68e61b --- /dev/null +++ b/src/view/com/modals/ModerationDetails.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {listUriToHref} from 'lib/strings/url-helpers' +import {Button} from '../util/forms/Button' + +export const snapPoints = [300] + +export function Component({ + context, + moderation, +}: { + context: 'account' | 'content' + moderation: ModerationUI +}) { + const store = useStores() + const pal = usePalette('default') + + let name + let description + if (!moderation.cause) { + name = 'Content Warning' + description = + 'Moderator has chosen to set a general warning on the content.' + } else if (moderation.cause.type === 'blocking') { + name = 'User Blocked' + description = 'You have blocked this user. You cannot view their content.' + } else if (moderation.cause.type === 'blocked-by') { + name = 'User Blocks You' + description = 'This user has blocked you. You cannot view their content.' + } else if (moderation.cause.type === 'block-other') { + name = 'Content Not Available' + description = + 'This content is not available because one of the users involved has blocked the other.' + } else if (moderation.cause.type === 'muted') { + if (moderation.cause.source.type === 'list') { + const list = moderation.cause.source.list + name = <>Account Muted by List</> + description = ( + <> + This user is included the{' '} + <TextLink + type="2xl" + href={listUriToHref(list.uri)} + text={list.name} + style={pal.link} + />{' '} + list which you have muted. + </> + ) + } else { + name = 'Account Muted' + description = 'You have muted this user.' + } + } else { + name = moderation.cause.labelDef.strings[context].en.name + description = moderation.cause.labelDef.strings[context].en.description + } + + return ( + <View testID="moderationDetailsModal" style={[styles.container, pal.view]}> + <Text type="title-xl" style={[pal.text, styles.title]}> + {name} + </Text> + <Text type="2xl" style={[pal.text, styles.description]}> + {description} + </Text> + <View style={s.flex1} /> + <Button + type="primary" + style={styles.btn} + onPress={() => store.shell.closeModal()}> + <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}> + Okay + </Text> + </Button> + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: isDesktopWeb ? 0 : 14, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + marginBottom: 12, + }, + description: { + textAlign: 'center', + }, + btn: { + paddingVertical: 14, + marginTop: isDesktopWeb ? 40 : 0, + marginBottom: isDesktopWeb ? 0 : 40, + }, +}) diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index d3267644b..4efe81225 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -1,63 +1,56 @@ -import React, {useState, useEffect, useCallback} from 'react' -import {StyleSheet, View} from 'react-native' +import React, {useState, useEffect} from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {useNavigation, StackActions} from '@react-navigation/native' -import {Text} from '../util/text/Text' +import {ThemedText} from '../util/text/ThemedText' import {useStores} from 'state/index' import {ProfileModel} from 'state/models/content/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {ProfileHeader} from '../profile/ProfileHeader' -import {Button} from '../util/forms/Button' -import {NavigationProp} from 'lib/routes/types' +import {InfoCircleIcon} from 'lib/icons' +import {useNavigationState} from '@react-navigation/native' +import {isIOS} from 'platform/detection' +import {s} from 'lib/styles' -export const snapPoints = [560] +export const snapPoints = [520, '100%'] export const Component = observer(({did}: {did: string}) => { const store = useStores() const pal = usePalette('default') - const palInverted = usePalette('inverted') - const navigation = useNavigation<NavigationProp>() const [model] = useState(new ProfileModel(store, {actor: did})) const {screen} = useAnalytics() + // track the navigator state to detect if a page-load occurred + const navState = useNavigationState(s => s) + const [initNavState] = useState(navState) + const isLoading = initNavState !== navState + useEffect(() => { screen('Profile:Preview') model.setup() }, [model, screen]) - const onPressViewProfile = useCallback(() => { - navigation.dispatch(StackActions.push('Profile', {name: model.handle})) - store.shell.closeModal() - }, [navigation, store, model]) - return ( - <View style={pal.view}> - <View style={styles.headerWrapper}> + <View style={[pal.view, s.flex1]}> + <View + style={[ + styles.headerWrapper, + isLoading && isIOS && styles.headerPositionAdjust, + ]}> <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} /> </View> - <View style={[styles.buttonsContainer, pal.view]}> - <View style={styles.buttons}> - <Button - type="inverted" - style={[styles.button, styles.buttonWide]} - onPress={onPressViewProfile} - accessibilityLabel="View profile" - accessibilityHint=""> - <Text type="button-lg" style={palInverted.text}> - View Profile - </Text> - </Button> - <Button - type="default" - style={styles.button} - onPress={() => store.shell.closeModal()} - accessibilityLabel="Close this preview" - accessibilityHint=""> - <Text type="button-lg" style={pal.text}> - Close - </Text> - </Button> + <View style={[styles.hintWrapper, pal.view]}> + <View style={styles.hint}> + {isLoading ? ( + <ActivityIndicator /> + ) : ( + <> + <InfoCircleIcon size={21} style={pal.textLight} /> + <ThemedText type="xl" fg="light"> + Swipe up to see more + </ThemedText> + </> + )} </View> </View> </View> @@ -68,22 +61,18 @@ const styles = StyleSheet.create({ headerWrapper: { height: 440, }, - buttonsContainer: { - height: 120, + headerPositionAdjust: { + // HACK align the header for the profilescreen transition -prf + paddingTop: 23, }, - buttons: { - flexDirection: 'row', - gap: 8, - paddingHorizontal: 14, - paddingTop: 16, + hintWrapper: { + height: 80, }, - button: { - flex: 2, + hint: { flexDirection: 'row', justifyContent: 'center', - paddingVertical: 12, - }, - buttonWide: { - flex: 3, + gap: 8, + paddingHorizontal: 14, + borderRadius: 6, }, }) diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx new file mode 100644 index 000000000..42863fd33 --- /dev/null +++ b/src/view/com/modals/SelfLabel.tsx @@ -0,0 +1,191 @@ +import React, {useState} from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {Text} from '../util/text/Text' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {Button} from '../util/forms/Button' +import {SelectableBtn} from '../util/forms/SelectableBtn' +import {ScrollView} from 'view/com/modals/util' + +const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn'] + +export const snapPoints = ['50%'] + +export const Component = observer(function Component({ + labels, + hasMedia, + onChange, +}: { + labels: string[] + hasMedia: boolean + onChange: (labels: string[]) => void +}) { + const pal = usePalette('default') + const store = useStores() + const [selected, setSelected] = useState(labels) + + const toggleAdultLabel = (label: string) => { + const hadLabel = selected.includes(label) + const stripped = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l)) + const final = !hadLabel ? stripped.concat([label]) : stripped + setSelected(final) + onChange(final) + } + + const removeAdultLabel = () => { + const final = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l)) + setSelected(final) + onChange(final) + } + + const hasAdultSelection = + selected.includes('sexual') || + selected.includes('nudity') || + selected.includes('porn') + return ( + <View testID="selfLabelModal" style={[pal.view, styles.container]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + Add a content warning + </Text> + </View> + + <ScrollView> + <View style={[styles.section, pal.border, {borderBottomWidth: 1}]}> + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingBottom: 8, + }}> + <Text type="title" style={pal.text}> + Adult Content + </Text> + {hasAdultSelection ? ( + <Button + type="default-light" + onPress={removeAdultLabel} + style={{paddingTop: 0, paddingBottom: 0, paddingRight: 0}}> + <Text type="md" style={pal.link}> + Remove + </Text> + </Button> + ) : null} + </View> + {hasMedia ? ( + <> + <View style={s.flexRow}> + <SelectableBtn + testID="sexualLabelBtn" + selected={selected.includes('sexual')} + left + label="Suggestive" + onSelect={() => toggleAdultLabel('sexual')} + accessibilityHint="" + style={s.flex1} + /> + <SelectableBtn + testID="nudityLabelBtn" + selected={selected.includes('nudity')} + label="Nudity" + onSelect={() => toggleAdultLabel('nudity')} + accessibilityHint="" + style={s.flex1} + /> + <SelectableBtn + testID="pornLabelBtn" + selected={selected.includes('porn')} + label="Porn" + right + onSelect={() => toggleAdultLabel('porn')} + accessibilityHint="" + style={s.flex1} + /> + </View> + + <Text style={[pal.text, styles.adultExplainer]}> + {selected.includes('sexual') ? ( + <>Pictures meant for adults.</> + ) : selected.includes('nudity') ? ( + <>Artistic or non-erotic nudity.</> + ) : selected.includes('porn') ? ( + <>Sexual activity or erotic nudity.</> + ) : ( + <>If none are selected, suitable for all ages.</> + )} + </Text> + </> + ) : ( + <View> + <Text style={[pal.textLight]}> + <Text type="md-bold" style={[pal.textLight]}> + Not Applicable + </Text> + . This warning is only available for posts with media attached. + </Text> + </View> + )} + </View> + </ScrollView> + + <View style={[styles.btnContainer, pal.borderDark]}> + <TouchableOpacity + testID="confirmBtn" + onPress={() => { + store.shell.closeModal() + }} + style={styles.btn} + accessibilityRole="button" + accessibilityLabel="Confirm" + accessibilityHint=""> + <Text style={[s.white, s.bold, s.f18]}>Done</Text> + </TouchableOpacity> + </View> + </View> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 40, + }, + titleSection: { + paddingTop: isDesktopWeb ? 0 : 4, + paddingBottom: isDesktopWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + description: { + textAlign: 'center', + paddingHorizontal: 32, + }, + section: { + borderTopWidth: 1, + paddingVertical: 20, + paddingHorizontal: isDesktopWeb ? 0 : 20, + }, + adultExplainer: { + paddingLeft: 5, + paddingTop: 10, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + paddingHorizontal: 20, + }, +}) diff --git a/src/view/com/modals/report/ReportPost.tsx b/src/view/com/modals/report/Modal.tsx index 34ec8c2f2..f386b110d 100644 --- a/src/view/com/modals/report/ReportPost.tsx +++ b/src/view/com/modals/report/Modal.tsx @@ -1,10 +1,9 @@ import React, {useState, useMemo} from 'react' import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' import {ScrollView} from 'react-native-gesture-handler' -import {ComAtprotoModerationDefs} from '@atproto/api' +import {AtUri} from '@atproto/api' import {useStores} from 'state/index' import {s} from 'lib/styles' -import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup' import {Text} from '../../util/text/Text' import * as Toast from '../../util/Toast' import {ErrorMessage} from '../../util/error/ErrorMessage' @@ -12,25 +11,43 @@ import {cleanError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {SendReportButton} from './SendReportButton' import {InputIssueDetails} from './InputIssueDetails' +import {ReportReasonOptions} from './ReasonOptions' +import {CollectionId} from './types' const DMCA_LINK = 'https://bsky.app/support/copyright' export const snapPoints = [575] -export function Component({ - postUri, - postCid, -}: { - postUri: string - postCid: string -}) { +const CollectionNames = { + [CollectionId.FeedGenerator]: 'Feed', + [CollectionId.Profile]: 'Profile', + [CollectionId.List]: 'List', + [CollectionId.Post]: 'Post', +} + +type ReportComponentProps = + | { + uri: string + cid: string + } + | { + did: string + } + +export function Component(content: ReportComponentProps) { const store = useStores() const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState(false) - const [showTextInput, setShowTextInput] = useState(false) + const [showDetailsInput, setShowDetailsInput] = useState(false) const [error, setError] = useState<string>() const [issue, setIssue] = useState<string>() const [details, setDetails] = useState<string>() + const isAccountReport = 'did' in content + const subjectKey = isAccountReport ? content.did : content.uri + const atUri = useMemo( + () => (!isAccountReport ? new AtUri(subjectKey) : null), + [isAccountReport, subjectKey], + ) const submitReport = async () => { setError('') @@ -43,12 +60,14 @@ export function Component({ Linking.openURL(DMCA_LINK) return } + const $type = !isAccountReport + ? 'com.atproto.repo.strongRef' + : 'com.atproto.admin.defs#repoRef' await store.agent.createModerationReport({ reasonType: issue, subject: { - $type: 'com.atproto.repo.strongRef', - uri: postUri, - cid: postCid, + $type, + ...content, }, reason: details, }) @@ -63,13 +82,13 @@ export function Component({ } const goBack = () => { - setShowTextInput(false) + setShowDetailsInput(false) } return ( - <ScrollView testID="reportPostModal" style={[s.flex1, pal.view]}> + <ScrollView testID="reportModal" style={[s.flex1, pal.view]}> <View style={styles.container}> - {showTextInput ? ( + {showDetailsInput ? ( <InputIssueDetails details={details} setDetails={setDetails} @@ -79,12 +98,13 @@ export function Component({ /> ) : ( <SelectIssue - setShowTextInput={setShowTextInput} + setShowDetailsInput={setShowDetailsInput} error={error} issue={issue} setIssue={setIssue} submitReport={submitReport} isProcessing={isProcessing} + atUri={atUri} /> )} </View> @@ -92,128 +112,59 @@ export function Component({ ) } +// If no atUri is passed, that means the reporting collection is account +const getCollectionNameForReport = (atUri: AtUri | null) => { + if (!atUri) return 'Account' + // Generic fallback for any collection being reported + return CollectionNames[atUri.collection as CollectionId] || 'Content' +} + const SelectIssue = ({ error, - setShowTextInput, + setShowDetailsInput, issue, setIssue, submitReport, isProcessing, + atUri, }: { error: string | undefined - setShowTextInput: (v: boolean) => void + setShowDetailsInput: (v: boolean) => void issue: string | undefined setIssue: (v: string) => void submitReport: () => void isProcessing: boolean + atUri: AtUri | null }) => { const pal = usePalette('default') - const ITEMS: RadioGroupItem[] = useMemo( - () => [ - { - key: ComAtprotoModerationDefs.REASONSPAM, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Spam - </Text> - <Text style={pal.textLight}>Excessive mentions or replies</Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONSEXUAL, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Unwanted Sexual Content - </Text> - <Text style={pal.textLight}> - Nudity or pornography not labeled as such - </Text> - </View> - ), - }, - { - key: '__copyright__', - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Copyright Violation - </Text> - <Text style={pal.textLight}>Contains copyrighted material</Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONRUDE, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Anti-Social Behavior - </Text> - <Text style={pal.textLight}> - Harassment, trolling, or intolerance - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONVIOLATION, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Illegal and Urgent - </Text> - <Text style={pal.textLight}> - Glaring violations of law or terms of service - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONOTHER, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Other - </Text> - <Text style={pal.textLight}> - An issue not included in these options - </Text> - </View> - ), - }, - ], - [pal], - ) - + const collectionName = getCollectionNameForReport(atUri) const onSelectIssue = (v: string) => setIssue(v) const goToDetails = () => { if (issue === '__copyright__') { Linking.openURL(DMCA_LINK) return } - setShowTextInput(true) + setShowDetailsInput(true) } return ( <> - <Text style={[pal.text, styles.title]}>Report post</Text> + <Text style={[pal.text, styles.title]}>Report {collectionName}</Text> <Text style={[pal.textLight, styles.description]}> - What is the issue with this post? + What is the issue with this {collectionName}? </Text> - <RadioGroup - testID="reportPostRadios" - items={ITEMS} - onSelect={onSelectIssue} + <ReportReasonOptions + atUri={atUri} + selectedIssue={issue} + onSelectIssue={onSelectIssue} /> {error ? ( <View style={s.mt10}> <ErrorMessage message={error} /> </View> ) : undefined} - {issue ? ( + {/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */} + {issue || !atUri ? ( <> <SendReportButton onPress={submitReport} diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx new file mode 100644 index 000000000..23b49b664 --- /dev/null +++ b/src/view/com/modals/report/ReasonOptions.tsx @@ -0,0 +1,123 @@ +import {View} from 'react-native' +import React, {useMemo} from 'react' +import {AtUri, ComAtprotoModerationDefs} from '@atproto/api' + +import {Text} from '../../util/text/Text' +import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette' +import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup' +import {CollectionId} from './types' + +type ReasonMap = Record<string, {title: string; description: string}> +const CommonReasons = { + [ComAtprotoModerationDefs.REASONRUDE]: { + title: 'Anti-Social Behavior', + description: 'Harassment, trolling, or intolerance', + }, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Illegal and Urgent', + description: 'Glaring violations of law or terms of service', + }, + [ComAtprotoModerationDefs.REASONOTHER]: { + title: 'Other', + description: 'An issue not included in these options', + }, +} +const CollectionToReasonsMap: Record<string, ReasonMap> = { + [CollectionId.Post]: { + [ComAtprotoModerationDefs.REASONSPAM]: { + title: 'Spam', + description: 'Excessive mentions or replies', + }, + [ComAtprotoModerationDefs.REASONSEXUAL]: { + title: 'Unwanted Sexual Content', + description: 'Nudity or pornography not labeled as such', + }, + __copyright__: { + title: 'Copyright Violation', + description: 'Contains copyrighted material', + }, + ...CommonReasons, + }, + [CollectionId.List]: { + ...CommonReasons, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Name or Description Violates Community Standards', + description: 'Terms used violate community standards', + }, + }, +} +const AccountReportReasons = { + [ComAtprotoModerationDefs.REASONMISLEADING]: { + title: 'Misleading Account', + description: 'Impersonation or false claims about identity or affiliation', + }, + [ComAtprotoModerationDefs.REASONSPAM]: { + title: 'Frequently Posts Unwanted Content', + description: 'Spam; excessive mentions or replies', + }, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Name or Description Violates Community Standards', + description: 'Terms used violate community standards', + }, +} + +const Option = ({ + pal, + title, + description, +}: { + pal: UsePaletteValue + description: string + title: string +}) => { + return ( + <View> + <Text style={pal.text} type="md-bold"> + {title} + </Text> + <Text style={pal.textLight}>{description}</Text> + </View> + ) +} + +// This is mostly just content copy without almost any logic +// so this may grow over time and it makes sense to split it up into its own file +// to keep it separate from the actual reporting modal logic +const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) => + useMemo(() => { + let items: ReasonMap = {...CommonReasons} + // If no atUri is passed, that means the reporting collection is account + if (!atUri) { + items = {...AccountReportReasons} + } + + if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) { + items = {...CollectionToReasonsMap[atUri.collection]} + } + + return Object.entries(items).map(([key, {title, description}]) => ({ + key, + label: <Option pal={pal} title={title} description={description} />, + })) + }, [pal, atUri]) + +export const ReportReasonOptions = ({ + atUri, + selectedIssue, + onSelectIssue, +}: { + atUri: AtUri | null + selectedIssue?: string + onSelectIssue: (key: string) => void +}) => { + const pal = usePalette('default') + const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri) + return ( + <RadioGroup + items={ITEMS} + onSelect={onSelectIssue} + testID="reportReasonRadios" + initialSelection={selectedIssue} + /> + ) +} diff --git a/src/view/com/modals/report/ReportAccount.tsx b/src/view/com/modals/report/ReportAccount.tsx deleted file mode 100644 index b53c54caa..000000000 --- a/src/view/com/modals/report/ReportAccount.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React, {useState, useMemo} from 'react' -import {TouchableOpacity, StyleSheet, View} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' -import {ComAtprotoModerationDefs} from '@atproto/api' -import {useStores} from 'state/index' -import {s} from 'lib/styles' -import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup' -import {Text} from '../../util/text/Text' -import * as Toast from '../../util/Toast' -import {ErrorMessage} from '../../util/error/ErrorMessage' -import {cleanError} from 'lib/strings/errors' -import {usePalette} from 'lib/hooks/usePalette' -import {isDesktopWeb} from 'platform/detection' -import {SendReportButton} from './SendReportButton' -import {InputIssueDetails} from './InputIssueDetails' - -export const snapPoints = [500] - -export function Component({did}: {did: string}) { - const store = useStores() - const pal = usePalette('default') - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState<string>() - const [issue, setIssue] = useState<string>() - const onSelectIssue = (v: string) => setIssue(v) - const [details, setDetails] = useState<string>() - const [showDetailsInput, setShowDetailsInput] = useState(false) - - const onPress = async () => { - setError('') - if (!issue) { - return - } - setIsProcessing(true) - try { - await store.agent.com.atproto.moderation.createReport({ - reasonType: issue, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did, - }, - reason: details, - }) - Toast.show("Thank you for your report! We'll look into it promptly.") - store.shell.closeModal() - return - } catch (e: any) { - setError(cleanError(e)) - setIsProcessing(false) - } - } - const goBack = () => { - setShowDetailsInput(false) - } - const goToDetails = () => { - setShowDetailsInput(true) - } - - return ( - <ScrollView - testID="reportAccountModal" - style={[styles.container, pal.view]}> - {showDetailsInput ? ( - <InputIssueDetails - submitReport={onPress} - setDetails={setDetails} - details={details} - isProcessing={isProcessing} - goBack={goBack} - /> - ) : ( - <SelectIssue - onPress={onPress} - onSelectIssue={onSelectIssue} - error={error} - isProcessing={isProcessing} - goToDetails={goToDetails} - /> - )} - </ScrollView> - ) -} - -const SelectIssue = ({ - onPress, - onSelectIssue, - error, - isProcessing, - goToDetails, -}: { - onPress: () => void - onSelectIssue: (v: string) => void - error: string | undefined - isProcessing: boolean - goToDetails: () => void -}) => { - const pal = usePalette('default') - const ITEMS: RadioGroupItem[] = useMemo( - () => [ - { - key: ComAtprotoModerationDefs.REASONMISLEADING, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Misleading Account - </Text> - <Text style={pal.textLight}> - Impersonation or false claims about identity or affiliation - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONSPAM, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Frequently Posts Unwanted Content - </Text> - <Text style={pal.textLight}> - Spam; excessive mentions or replies - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONVIOLATION, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Name or Description Violates Community Standards - </Text> - <Text style={pal.textLight}> - Terms used violate community standards - </Text> - </View> - ), - }, - ], - [pal], - ) - return ( - <> - <Text type="title-xl" style={[pal.text, styles.title]}> - Report Account - </Text> - <Text type="xl" style={[pal.text, styles.description]}> - What is the issue with this account? - </Text> - <RadioGroup - testID="reportAccountRadios" - items={ITEMS} - onSelect={onSelectIssue} - /> - <Text type="sm" style={[pal.text, styles.description, s.pt10]}> - For other issues, please report specific posts. - </Text> - {error ? ( - <View style={s.mt10}> - <ErrorMessage message={error} /> - </View> - ) : undefined} - <SendReportButton onPress={onPress} isProcessing={isProcessing} /> - <TouchableOpacity - testID="addDetailsBtn" - style={styles.addDetailsBtn} - onPress={goToDetails} - accessibilityRole="button" - accessibilityLabel="Add details" - accessibilityHint="Add more details to your report"> - <Text style={[s.f18, pal.link]}>Add details to report</Text> - </TouchableOpacity> - </> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingHorizontal: isDesktopWeb ? 0 : 10, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 22, - marginBottom: 10, - }, - addDetailsBtn: { - padding: 14, - alignSelf: 'center', - marginBottom: 40, - }, -}) diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts new file mode 100644 index 000000000..ca947ecbd --- /dev/null +++ b/src/view/com/modals/report/types.ts @@ -0,0 +1,8 @@ +// TODO: ATM, @atproto/api does not export ids but it does have these listed at @atproto/api/client/lexicons +// once we start exporting the ids from the @atproto/ap package, replace these hardcoded ones +export enum CollectionId { + FeedGenerator = 'app.bsky.feed.generator', + Profile = 'app.bsky.actor.profile', + List = 'app.bsky.graph.list', + Post = 'app.bsky.feed.post', +} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 7b9f0715b..7b07bb30f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -7,7 +7,11 @@ import { StyleSheet, View, } from 'react-native' -import {AppBskyEmbedImages} from '@atproto/api' +import { + AppBskyEmbedImages, + ProfileModeration, + moderateProfile, +} from '@atproto/api' import {AtUri} from '@atproto/api' import { FontAwesomeIcon, @@ -31,11 +35,6 @@ import {Link, TextLink} from '../util/Link' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import { - getProfileViewBasicLabelInfo, - getProfileModeration, -} from 'lib/labeling/helpers' -import {ProfileModeration} from 'lib/labeling/types' import {formatCount} from '../util/numeric/format' import {makeProfileLink} from 'lib/routes/links' @@ -99,9 +98,9 @@ export const FeedItem = observer(function ({ handle: item.author.handle, displayName: item.author.displayName, avatar: item.author.avatar, - moderation: getProfileModeration( - store, - getProfileViewBasicLabelInfo(item.author), + moderation: moderateProfile( + item.author, + store.preferences.moderationOpts, ), }, ...(item.additional?.map(({author}) => { @@ -111,10 +110,7 @@ export const FeedItem = observer(function ({ handle: author.handle, displayName: author.displayName, avatar: author.avatar, - moderation: getProfileModeration( - store, - getProfileViewBasicLabelInfo(author), - ), + moderation: moderateProfile(author, store.preferences.moderationOpts), } }) || []), ] @@ -175,7 +171,7 @@ export const FeedItem = observer(function ({ action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'` icon = 'HeartIconSolid' iconStyle = [ - s.red3 as FontAwesomeIconStyle, + s.likeColor as FontAwesomeIconStyle, {position: 'relative', top: -4}, ] } else { diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 51f63dbb3..399e47006 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -20,25 +20,37 @@ import {ComposePrompt} from '../composer/Prompt' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' import {s} from 'lib/styles' -import {isDesktopWeb, isMobileWeb} from 'platform/detection' +import {isIOS, isDesktopWeb, isMobileWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {sanitizeDisplayName} from 'lib/strings/display-names' +const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 0} + +const PARENT_SPINNER = { + _reactKey: '__parent_spinner__', + _isHighlightedPost: false, +} const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} +const CHILD_SPINNER = { + _reactKey: '__child_spinner__', + _isHighlightedPost: false, +} const BOTTOM_COMPONENT = { _reactKey: '__bottom_component__', _isHighlightedPost: false, } type YieldedItem = | PostThreadItemModel + | typeof PARENT_SPINNER | typeof REPLY_PROMPT | typeof DELETED | typeof BLOCKED + | typeof PARENT_SPINNER export const PostThread = observer(function PostThread({ uri, @@ -51,14 +63,24 @@ export const PostThread = observer(function PostThread({ }) { const pal = usePalette('default') const ref = useRef<FlatList>(null) + const hasScrolledIntoView = useRef<boolean>(false) const [isRefreshing, setIsRefreshing] = React.useState(false) const navigation = useNavigation<NavigationProp>() const posts = React.useMemo(() => { if (view.thread) { - return Array.from(flattenThread(view.thread)).concat([BOTTOM_COMPONENT]) + const arr = Array.from(flattenThread(view.thread)) + if (view.isLoadingFromCache) { + if (view.thread?.postRecord?.reply) { + arr.unshift(PARENT_SPINNER) + } + arr.push(CHILD_SPINNER) + } else { + arr.push(BOTTOM_COMPONENT) + } + return arr } return [] - }, [view.thread]) + }, [view.isLoadingFromCache, view.thread]) useSetTitle( view.thread?.postRecord && `${sanitizeDisplayName( @@ -80,17 +102,37 @@ export const PostThread = observer(function PostThread({ setIsRefreshing(false) }, [view, setIsRefreshing]) - const onLayout = React.useCallback(() => { + const onContentSizeChange = React.useCallback(() => { + // only run once + if (hasScrolledIntoView.current) { + return + } + + // wait for loading to finish + if ( + !view.hasContent || + (view.isFromCache && view.isLoadingFromCache) || + view.isLoading + ) { + return + } + const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) { ref.current?.scrollToIndex({ index, animated: false, - viewOffset: 40, + viewPosition: 0, }) + hasScrolledIntoView.current = true } - }, [posts, ref]) - + }, [ + posts, + view.hasContent, + view.isFromCache, + view.isLoadingFromCache, + view.isLoading, + ]) const onScrollToIndexFailed = React.useCallback( (info: { index: number @@ -115,7 +157,13 @@ export const PostThread = observer(function PostThread({ const renderItem = React.useCallback( ({item}: {item: YieldedItem}) => { - if (item === REPLY_PROMPT) { + if (item === PARENT_SPINNER) { + return ( + <View style={styles.parentSpinner}> + <ActivityIndicator /> + </View> + ) + } else if (item === REPLY_PROMPT) { return <ComposePrompt onPressCompose={onPressReply} /> } else if (item === DELETED) { return ( @@ -150,6 +198,12 @@ export const PostThread = observer(function PostThread({ ]} /> ) + } else if (item === CHILD_SPINNER) { + return ( + <View style={styles.childSpinner}> + <ActivityIndicator /> + </View> + ) } else if (item instanceof PostThreadItemModel) { return <PostThreadItem item={item} onPostReply={onRefresh} /> } @@ -247,6 +301,11 @@ export const PostThread = observer(function PostThread({ ref={ref} data={posts} initialNumToRender={posts.length} + maintainVisibleContentPosition={ + isIOS && view.isFromCache + ? MAINTAIN_VISIBLE_CONTENT_POSITION + : undefined + } keyExtractor={item => item._reactKey} renderItem={renderItem} refreshControl={ @@ -257,10 +316,12 @@ export const PostThread = observer(function PostThread({ titleColor={pal.colors.text} /> } - onLayout={onLayout} + onContentSizeChange={ + isIOS && view.isFromCache ? undefined : onContentSizeChange + } onScrollToIndexFailed={onScrollToIndexFailed} style={s.hContentRegion} - contentContainerStyle={s.contentContainerExtra} + contentContainerStyle={styles.contentContainerExtra} /> ) }) @@ -307,10 +368,17 @@ const styles = StyleSheet.create({ paddingHorizontal: 18, paddingVertical: 18, }, + parentSpinner: { + paddingVertical: 10, + }, + childSpinner: {}, bottomBorder: { borderBottomWidth: 1, }, bottomSpacer: { - height: 200, + height: 400, + }, + contentContainerExtra: { + paddingBottom: 500, }, }) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index edf8d7749..8a56012f0 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -26,15 +26,14 @@ import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' -import {ImageHider} from '../util/moderation/ImageHider' +import {PostAlerts} from '../util/moderation/PostAlerts' import {PostSandboxWarning} from '../util/PostSandboxWarning' import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' import {formatCount} from '../util/numeric/format' import {TimeElapsed} from 'view/com/util/TimeElapsed' import {makeProfileLink} from 'lib/routes/links' - -const PARENT_REPLY_LINE_LENGTH = 8 +import {isDesktopWeb} from 'platform/detection' export const PostThreadItem = observer(function PostThreadItem({ item, @@ -69,8 +68,7 @@ export const PostThreadItem = observer(function PostThreadItem({ }, [item.post.uri, item.post.author]) const repostsTitle = 'Reposts of this post' - const primaryLanguage = store.preferences.contentLanguages[0] || 'en' - const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '') + const translatorUrl = getTranslatorLink(record?.text || '') const needsTranslation = useMemo( () => store.preferences.contentLanguages.length > 0 && @@ -159,159 +157,197 @@ export const PostThreadItem = observer(function PostThreadItem({ if (item._isHighlightedPost) { return ( - <PostHider - testID={`postThreadItem-by-${item.post.author.handle}`} - style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} - moderation={item.moderation.thread}> - <PostSandboxWarning /> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <PreviewableUserAvatar - size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} - /> + <> + {item.rootUri !== item.uri && ( + <View style={{paddingLeft: 18, flexDirection: 'row', height: 16}}> + <View style={{width: 52}}> + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + }, + ]} + /> + </View> </View> - <View style={styles.layoutContent}> - <View style={[styles.meta, styles.metaExpandedLine1]}> - <View style={[s.flexRow]}> + )} + + <Link + testID={`postThreadItem-by-${item.post.author.handle}`} + style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} + noFeedback + accessible={false}> + <PostSandboxWarning /> + <View style={styles.layout}> + <View style={[styles.layoutAvi, {paddingBottom: 8}]}> + <PreviewableUserAvatar + size={52} + did={item.post.author.did} + handle={item.post.author.handle} + avatar={item.post.author.avatar} + moderation={item.moderation.avatar} + /> + </View> + <View style={styles.layoutContent}> + <View style={[styles.meta, styles.metaExpandedLine1]}> + <View style={[s.flexRow]}> + <Link + style={styles.metaItem} + href={authorHref} + title={authorTitle}> + <Text + type="xl-bold" + style={[pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName( + item.post.author.displayName || + sanitizeHandle(item.post.author.handle), + )} + </Text> + </Link> + <Text type="md" style={[styles.metaItem, pal.textLight]}> + · + <TimeElapsed timestamp={item.post.indexedAt}> + {({timeElapsed}) => <>{timeElapsed}</>} + </TimeElapsed> + </Text> + </View> + </View> + <View style={styles.meta}> <Link style={styles.metaItem} href={authorHref} title={authorTitle}> - <Text - type="xl-bold" - style={[pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {sanitizeDisplayName( - item.post.author.displayName || - sanitizeHandle(item.post.author.handle), - )} + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {sanitizeHandle(item.post.author.handle, '@')} </Text> </Link> - <Text type="md" style={[styles.metaItem, pal.textLight]}> - · - <TimeElapsed timestamp={item.post.indexedAt}> - {({timeElapsed}) => <>{timeElapsed}</>} - </TimeElapsed> - </Text> - </View> - <View style={s.flex1} /> - <PostDropdownBtn - testID="postDropdownBtn" - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - isAuthor={item.post.author.did === store.me.did} - isThreadMuted={item.isThreadMuted} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} - /> - </View> - <View style={styles.meta}> - <Link - style={styles.metaItem} - href={authorHref} - title={authorTitle}> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {sanitizeHandle(item.post.author.handle, '@')} - </Text> - </Link> - </View> - </View> - </View> - <View style={[s.pl10, s.pr10, s.pb10]}> - <ContentHider moderation={item.moderation.view}> - {item.richText?.text ? ( - <View - style={[ - styles.postTextContainer, - styles.postTextLargeContainer, - ]}> - <RichText - type="post-text-lg" - richText={item.richText} - lineHeight={1.3} - style={s.flex1} - /> </View> - ) : undefined} - <ImageHider moderation={item.moderation.view} style={s.mb10}> - <PostEmbeds embed={item.post.embed} style={s.mb10} /> - </ImageHider> - </ContentHider> - <ExpandedPostDetails - post={item.post} - translatorUrl={translatorUrl} - needsTranslation={needsTranslation} - /> - {hasEngagement ? ( - <View style={[styles.expandedInfo, pal.border]}> - {item.post.repostCount ? ( - <Link - style={styles.expandedInfoItem} - href={repostsHref} - title={repostsTitle}> - <Text testID="repostCount" type="lg" style={pal.textLight}> - <Text type="xl-bold" style={pal.text}> - {formatCount(item.post.repostCount)} - </Text>{' '} - {pluralize(item.post.repostCount, 'repost')} - </Text> - </Link> - ) : ( - <></> - )} - {item.post.likeCount ? ( - <Link - style={styles.expandedInfoItem} - href={likesHref} - title={likesTitle}> - <Text testID="likeCount" type="lg" style={pal.textLight}> - <Text type="xl-bold" style={pal.text}> - {formatCount(item.post.likeCount)} - </Text>{' '} - {pluralize(item.post.likeCount, 'like')} - </Text> - </Link> - ) : ( - <></> - )} </View> - ) : ( - <></> - )} - <View style={[s.pl10, s.pb5]}> - <PostCtrls - big + <PostDropdownBtn + testID="postDropdownBtn" itemUri={itemUri} itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} - author={item.post.author} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} isAuthor={item.post.author.did === store.me.did} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} isThreadMuted={item.isThreadMuted} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onToggleThreadMute={onToggleThreadMute} onDeletePost={onDeletePost} + style={{ + paddingVertical: 6, + paddingHorizontal: 10, + marginLeft: 'auto', + width: 40, + }} + /> + </View> + <View style={[s.pl10, s.pr10, s.pb10]}> + <ContentHider + moderation={item.moderation.content} + ignoreMute + style={styles.contentHider} + childContainerStyle={styles.contentHiderChild}> + <PostAlerts + moderation={item.moderation.content} + includeMute + style={styles.alert} + /> + {item.richText?.text ? ( + <View + style={[ + styles.postTextContainer, + styles.postTextLargeContainer, + ]}> + <RichText + type="post-text-lg" + richText={item.richText} + lineHeight={1.3} + style={s.flex1} + /> + </View> + ) : undefined} + {item.post.embed && ( + <ContentHider moderation={item.moderation.embed} style={s.mb10}> + <PostEmbeds + embed={item.post.embed} + moderation={item.moderation.embed} + /> + </ContentHider> + )} + </ContentHider> + <ExpandedPostDetails + post={item.post} + translatorUrl={translatorUrl} + needsTranslation={needsTranslation} /> + {hasEngagement ? ( + <View style={[styles.expandedInfo, pal.border]}> + {item.post.repostCount ? ( + <Link + style={styles.expandedInfoItem} + href={repostsHref} + title={repostsTitle}> + <Text testID="repostCount" type="lg" style={pal.textLight}> + <Text type="xl-bold" style={pal.text}> + {formatCount(item.post.repostCount)} + </Text>{' '} + {pluralize(item.post.repostCount, 'repost')} + </Text> + </Link> + ) : ( + <></> + )} + {item.post.likeCount ? ( + <Link + style={styles.expandedInfoItem} + href={likesHref} + title={likesTitle}> + <Text testID="likeCount" type="lg" style={pal.textLight}> + <Text type="xl-bold" style={pal.text}> + {formatCount(item.post.likeCount)} + </Text>{' '} + {pluralize(item.post.likeCount, 'like')} + </Text> + </Link> + ) : ( + <></> + )} + </View> + ) : ( + <></> + )} + <View style={[s.pl10, s.pb5]}> + <PostCtrls + big + itemUri={itemUri} + itemCid={itemCid} + itemHref={itemHref} + itemTitle={itemTitle} + author={item.post.author} + text={item.richText?.text || record.text} + indexedAt={item.post.indexedAt} + isAuthor={item.post.author.did === store.me.did} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} + isThreadMuted={item.isThreadMuted} + onPressReply={onPressReply} + onPressToggleRepost={onPressToggleRepost} + onPressToggleLike={onPressToggleLike} + onCopyPostText={onCopyPostText} + onOpenTranslate={onOpenTranslate} + onToggleThreadMute={onToggleThreadMute} + onDeletePost={onDeletePost} + /> + </View> </View> - </View> - </PostHider> + </Link> + </> ) } else { return ( @@ -324,26 +360,36 @@ export const PostThreadItem = observer(function PostThreadItem({ pal.border, pal.view, item._showParentReplyLine && styles.noTopBorder, + !item._showChildReplyLine && {borderBottomWidth: 1}, ]} - moderation={item.moderation.thread}> - {item._showParentReplyLine && ( - <View - style={[ - styles.parentReplyLine, - {borderColor: pal.colors.replyLine}, - ]} - /> - )} - {item._showChildReplyLine && ( - <View - style={[ - styles.childReplyLine, - {borderColor: pal.colors.replyLine}, - ]} - /> - )} + moderation={item.moderation.content}> <PostSandboxWarning /> - <View style={styles.layout}> + + <View + style={{flexDirection: 'row', gap: 10, paddingLeft: 8, height: 16}}> + <View style={{width: 52}}> + {item._showParentReplyLine && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginBottom: 4, + }, + ]} + /> + )} + </View> + </View> + + <View + style={[ + styles.layout, + { + paddingBottom: item._showChildReplyLine ? 0 : 8, + }, + ]}> <View style={styles.layoutAvi}> <PreviewableUserAvatar size={52} @@ -352,7 +398,21 @@ export const PostThreadItem = observer(function PostThreadItem({ avatar={item.post.author.avatar} moderation={item.moderation.avatar} /> + + {item._showChildReplyLine && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginTop: 4, + }, + ]} + /> + )} </View> + <View style={styles.layoutContent}> <PostMeta author={item.post.author} @@ -360,32 +420,39 @@ export const PostThreadItem = observer(function PostThreadItem({ timestamp={item.post.indexedAt} postHref={itemHref} /> - <ContentHider - moderation={item.moderation.thread} - containerStyle={styles.contentHider}> - {item.richText?.text ? ( - <View style={styles.postTextContainer}> - <RichText - type="post-text" - richText={item.richText} - style={[pal.text, s.flex1]} - lineHeight={1.3} - /> - </View> - ) : undefined} - <ImageHider style={s.mb10} moderation={item.moderation.thread}> - <PostEmbeds embed={item.post.embed} style={s.mb10} /> - </ImageHider> - {needsTranslation && ( - <View style={[pal.borderDark, styles.translateLink]}> - <Link href={translatorUrl} title="Translate"> - <Text type="sm" style={pal.link}> - Translate this post - </Text> - </Link> - </View> - )} - </ContentHider> + <PostAlerts + moderation={item.moderation.content} + style={styles.alert} + /> + {item.richText?.text ? ( + <View style={styles.postTextContainer}> + <RichText + type="post-text" + richText={item.richText} + style={[pal.text, s.flex1]} + lineHeight={1.3} + /> + </View> + ) : undefined} + {item.post.embed && ( + <ContentHider + style={styles.contentHider} + moderation={item.moderation.embed}> + <PostEmbeds + embed={item.post.embed} + moderation={item.moderation.embed} + /> + </ContentHider> + )} + {needsTranslation && ( + <View style={[pal.borderDark, styles.translateLink]}> + <Link href={translatorUrl} title="Translate"> + <Text type="sm" style={pal.link}> + Translate this post + </Text> + </Link> + </View> + )} <PostCtrls itemUri={itemUri} itemCid={itemCid} @@ -416,7 +483,7 @@ export const PostThreadItem = observer(function PostThreadItem({ <Link style={[ styles.loadMore, - {borderTopColor: pal.colors.border}, + {borderBottomColor: pal.colors.border}, pal.view, ]} href={itemHref} @@ -466,41 +533,22 @@ const styles = StyleSheet.create({ paddingLeft: 10, }, outerHighlighted: { - paddingTop: 2, - paddingLeft: 6, - paddingRight: 6, + paddingTop: 16, + paddingLeft: 10, + paddingRight: 10, }, noTopBorder: { borderTopWidth: 0, }, - parentReplyLine: { - position: 'absolute', - left: 44, - top: -1 * PARENT_REPLY_LINE_LENGTH + 6, - height: PARENT_REPLY_LINE_LENGTH, - borderLeftWidth: 2, - }, - childReplyLine: { - position: 'absolute', - left: 44, - top: 65, - bottom: 0, - borderLeftWidth: 2, - }, layout: { flexDirection: 'row', + gap: 10, + paddingLeft: 8, }, - layoutAvi: { - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - marginRight: 10, - }, + layoutAvi: {}, layoutContent: { flex: 1, paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, }, meta: { flexDirection: 'row', @@ -513,7 +561,10 @@ const styles = StyleSheet.create({ }, metaItem: { paddingRight: 5, - maxWidth: 240, + maxWidth: isDesktopWeb ? 380 : 220, + }, + alert: { + marginBottom: 6, }, postTextContainer: { flexDirection: 'row', @@ -521,7 +572,6 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', paddingBottom: 8, paddingRight: 10, - minHeight: 36, }, postTextLargeContainer: { paddingHorizontal: 0, @@ -531,7 +581,10 @@ const styles = StyleSheet.create({ marginBottom: 6, }, contentHider: { - marginTop: 4, + marginBottom: 6, + }, + contentHiderChild: { + marginTop: 6, }, expandedInfo: { flexDirection: 'row', @@ -547,10 +600,14 @@ const styles = StyleSheet.create({ loadMore: { flexDirection: 'row', justifyContent: 'space-between', - borderTopWidth: 1, + borderBottomWidth: 1, paddingLeft: 80, paddingRight: 20, - paddingVertical: 10, - marginBottom: 8, + paddingVertical: 12, + }, + replyLine: { + width: 2, + marginLeft: 'auto', + marginRight: 'auto', }, }) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index ac5e7d20b..673ddefcf 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -19,9 +19,8 @@ import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/post-ctrls/PostCtrls' -import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' -import {ImageHider} from '../util/moderation/ImageHider' +import {PostAlerts} from '../util/moderation/PostAlerts' import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' @@ -134,8 +133,7 @@ const PostLoaded = observer( replyAuthorDid = urip.hostname } - const primaryLanguage = store.preferences.contentLanguages[0] || 'en' - const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '') + const translatorUrl = getTranslatorLink(record?.text || '') const needsTranslation = useMemo( () => store.preferences.contentLanguages.length > 0 && @@ -206,10 +204,7 @@ const PostLoaded = observer( }, [item, setDeleted, store]) return ( - <PostHider - href={itemHref} - style={[styles.outer, pal.view, pal.border, style]} - moderation={item.moderation.list}> + <Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}> {showReplyLine && <View style={styles.replyLine} />} <View style={styles.layout}> <View style={styles.layoutAvi}> @@ -251,8 +246,13 @@ const PostLoaded = observer( </View> )} <ContentHider - moderation={item.moderation.list} - containerStyle={styles.contentHider}> + moderation={item.moderation.content} + style={styles.contentHider} + childContainerStyle={styles.contentHiderChild}> + <PostAlerts + moderation={item.moderation.content} + style={styles.alert} + /> {item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText @@ -264,9 +264,16 @@ const PostLoaded = observer( /> </View> ) : undefined} - <ImageHider moderation={item.moderation.list} style={s.mb10}> - <PostEmbeds embed={item.post.embed} style={s.mb10} /> - </ImageHider> + {item.post.embed ? ( + <ContentHider + moderation={item.moderation.embed} + style={styles.contentHider}> + <PostEmbeds + embed={item.post.embed} + moderation={item.moderation.embed} + /> + </ContentHider> + ) : null} {needsTranslation && ( <View style={[pal.borderDark, styles.translateLink]}> <Link href={translatorUrl} title="Translate"> @@ -302,15 +309,17 @@ const PostLoaded = observer( /> </View> </View> - </PostHider> + </Link> ) }, ) const styles = StyleSheet.create({ outer: { - padding: 10, + paddingTop: 10, paddingRight: 15, + paddingBottom: 5, + paddingLeft: 10, borderTopWidth: 1, }, layout: { @@ -323,11 +332,13 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, }, + alert: { + marginBottom: 6, + }, postTextContainer: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', - paddingBottom: 8, }, translateLink: { marginBottom: 12, @@ -341,6 +352,9 @@ const styles = StyleSheet.create({ borderLeftColor: colors.gray2, }, contentHider: { - marginTop: 4, + marginBottom: 2, + }, + contentHiderChild: { + marginTop: 6, }, }) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 75c321145..e1212f32c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -8,16 +8,14 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {PostsFeedItemModel} from 'state/models/feeds/post' -import {ModerationBehaviorCode} from 'lib/labeling/types' import {Link, DesktopWebTextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostEmbeds} from '../util/post-embeds' -import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' -import {ImageHider} from '../util/moderation/ImageHider' +import {PostAlerts} from '../util/moderation/PostAlerts' import {RichText} from '../util/text/RichText' import {PostSandboxWarning} from '../util/PostSandboxWarning' import * as Toast from '../util/Toast' @@ -34,14 +32,14 @@ import {makeProfileLink} from 'lib/routes/links' export const FeedItem = observer(function ({ item, isThreadChild, + isThreadLastChild, isThreadParent, - ignoreMuteFor, }: { item: PostsFeedItemModel isThreadChild?: boolean + isThreadLastChild?: boolean isThreadParent?: boolean showReplyLine?: boolean - ignoreMuteFor?: string }) { const store = useStores() const pal = usePalette('default') @@ -62,8 +60,7 @@ export const FeedItem = observer(function ({ const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) return urip.hostname }, [record?.reply]) - const primaryLanguage = store.preferences.contentLanguages[0] || 'en' - const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '') + const translatorUrl = getTranslatorLink(record?.text || '') const needsTranslation = useMemo( () => store.preferences.contentLanguages.length > 0 && @@ -138,80 +135,86 @@ export const FeedItem = observer(function ({ ) }, [track, item, setDeleted, store]) - const isSmallTop = isThreadChild const outerStyles = [ styles.outer, pal.view, - {borderColor: pal.colors.border}, - isSmallTop ? styles.outerSmallTop : undefined, - isThreadParent ? styles.outerNoBottom : undefined, + { + borderColor: pal.colors.border, + paddingBottom: + isThreadLastChild || (!isThreadChild && !isThreadParent) + ? 6 + : undefined, + }, + isThreadChild ? styles.outerSmallTop : undefined, ] - // moderation override - let moderation = item.moderation.list - if ( - ignoreMuteFor === item.post.author.did && - moderation.isMute && - !moderation.noOverride - ) { - moderation = {behavior: ModerationBehaviorCode.Show} - } - if (!record || deleted) { return <View /> } return ( - <PostHider + <Link testID={`feedItem-by-${item.post.author.handle}`} style={outerStyles} href={itemHref} - moderation={moderation}> - {isThreadChild && ( - <View - style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} - /> - )} - {isThreadParent && ( - <View - style={[styles.bottomReplyLine, {borderColor: pal.colors.replyLine}]} - /> - )} - {item.reasonRepost && ( - <Link - style={styles.includeReason} - href={makeProfileLink(item.reasonRepost.by)} - title={sanitizeDisplayName( - item.reasonRepost.by.displayName || item.reasonRepost.by.handle, - )}> - <FontAwesomeIcon - icon="retweet" - style={[ - styles.includeReasonIcon, - {color: pal.colors.textLight} as FontAwesomeIconStyle, - ]} - /> - <Text - type="sm-bold" - style={pal.textLight} - lineHeight={1.2} - numberOfLines={1}> - Reposted by{' '} - <DesktopWebTextLink - type="sm-bold" - style={pal.textLight} - lineHeight={1.2} - numberOfLines={1} - text={sanitizeDisplayName( - item.reasonRepost.by.displayName || - sanitizeHandle(item.reasonRepost.by.handle), - )} - href={makeProfileLink(item.reasonRepost.by)} - /> - </Text> - </Link> - )} + noFeedback + accessible={false}> <PostSandboxWarning /> + + <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> + <View style={{width: 52}}> + {isThreadChild && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginBottom: 4, + }, + ]} + /> + )} + </View> + + <View style={{paddingTop: 12}}> + {item.reasonRepost && ( + <Link + style={styles.includeReason} + href={makeProfileLink(item.reasonRepost.by)} + title={sanitizeDisplayName( + item.reasonRepost.by.displayName || item.reasonRepost.by.handle, + )}> + <FontAwesomeIcon + icon="retweet" + style={[ + styles.includeReasonIcon, + {color: pal.colors.textLight} as FontAwesomeIconStyle, + ]} + /> + <Text + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1}> + Reposted by{' '} + <DesktopWebTextLink + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + text={sanitizeDisplayName( + item.reasonRepost.by.displayName || + sanitizeHandle(item.reasonRepost.by.handle), + )} + href={makeProfileLink(item.reasonRepost.by)} + /> + </Text> + </Link> + )} + </View> + </View> + <View style={styles.layout}> <View style={styles.layoutAvi}> <PreviewableUserAvatar @@ -221,6 +224,18 @@ export const FeedItem = observer(function ({ avatar={item.post.author.avatar} moderation={item.moderation.avatar} /> + {isThreadParent && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginTop: 4, + }, + ]} + /> + )} </View> <View style={styles.layoutContent}> <PostMeta @@ -255,8 +270,14 @@ export const FeedItem = observer(function ({ </View> )} <ContentHider - moderation={moderation} - containerStyle={styles.contentHider}> + testID="contentHider-post" + moderation={item.moderation.content} + ignoreMute + childContainerStyle={styles.contentHiderChild}> + <PostAlerts + moderation={item.moderation.content} + style={styles.alert} + /> {item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText @@ -267,9 +288,17 @@ export const FeedItem = observer(function ({ /> </View> ) : undefined} - <ImageHider moderation={item.moderation.list} style={styles.embed}> - <PostEmbeds embed={item.post.embed} style={styles.embed} /> - </ImageHider> + {item.post.embed ? ( + <ContentHider + testID="contentHider-embed" + moderation={item.moderation.embed} + style={styles.embed}> + <PostEmbeds + embed={item.post.embed} + moderation={item.moderation.embed} + /> + </ContentHider> + ) : null} {needsTranslation && ( <View style={[pal.borderDark, styles.translateLink]}> <Link href={translatorUrl} title="Translate"> @@ -281,7 +310,6 @@ export const FeedItem = observer(function ({ )} </ContentHider> <PostCtrls - style={styles.ctrls} itemUri={itemUri} itemCid={itemCid} itemHref={itemHref} @@ -306,43 +334,29 @@ export const FeedItem = observer(function ({ /> </View> </View> - </PostHider> + </Link> ) }) const styles = StyleSheet.create({ outer: { borderTopWidth: 1, - padding: 10, + paddingLeft: 10, paddingRight: 15, - paddingBottom: 8, }, outerSmallTop: { borderTopWidth: 0, }, - outerNoBottom: { - paddingBottom: 2, - }, - topReplyLine: { - position: 'absolute', - left: 42, - top: 0, - height: 6, - borderLeftWidth: 2, - }, - bottomReplyLine: { - position: 'absolute', - left: 42, - top: 72, - bottom: 0, - borderLeftWidth: 2, + replyLine: { + width: 2, + marginLeft: 'auto', + marginRight: 'auto', }, includeReason: { flexDirection: 'row', - paddingLeft: 50, - paddingRight: 20, marginTop: 2, - marginBottom: 2, + marginBottom: 4, + marginLeft: -20, }, includeReasonIcon: { marginRight: 4, @@ -358,14 +372,18 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, }, + alert: { + marginTop: 6, + marginBottom: 6, + }, postTextContainer: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', paddingBottom: 4, }, - contentHider: { - marginTop: 4, + contentHiderChild: { + marginTop: 6, }, embed: { marginBottom: 6, @@ -373,7 +391,4 @@ const styles = StyleSheet.create({ translateLink: { marginBottom: 6, }, - ctrls: { - marginTop: 4, - }, }) diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index b73d4a99d..6fc169db9 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,5 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' import {AtUri} from '@atproto/api' import {Link} from '../util/Link' @@ -7,65 +8,65 @@ import {Text} from '../util/text/Text' import Svg, {Circle, Line} from 'react-native-svg' import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' -import {ModerationBehaviorCode} from 'lib/labeling/types' import {makeProfileLink} from 'lib/routes/links' -export function FeedSlice({ - slice, - ignoreMuteFor, -}: { - slice: PostsFeedSliceModel - ignoreMuteFor?: string -}) { - if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) { - if (!ignoreMuteFor && !slice.moderation.list.noOverride) { +export const FeedSlice = observer( + ({ + slice, + ignoreFilterFor, + }: { + slice: PostsFeedSliceModel + ignoreFilterFor?: string + }) => { + if (slice.shouldFilter(ignoreFilterFor)) { return null } - } - if (slice.isThread && slice.items.length > 3) { - const last = slice.items.length - 1 + + if (slice.isThread && slice.items.length > 3) { + const last = slice.items.length - 1 + return ( + <> + <FeedItem + key={slice.items[0]._reactKey} + item={slice.items[0]} + isThreadParent={slice.isThreadParentAt(0)} + isThreadChild={slice.isThreadChildAt(0)} + /> + <FeedItem + key={slice.items[1]._reactKey} + item={slice.items[1]} + isThreadParent={slice.isThreadParentAt(1)} + isThreadChild={slice.isThreadChildAt(1)} + /> + <ViewFullThread slice={slice} /> + <FeedItem + key={slice.items[last]._reactKey} + item={slice.items[last]} + isThreadParent={slice.isThreadParentAt(last)} + isThreadChild={slice.isThreadChildAt(last)} + isThreadLastChild + /> + </> + ) + } + return ( <> - <FeedItem - key={slice.items[0]._reactKey} - item={slice.items[0]} - isThreadParent={slice.isThreadParentAt(0)} - isThreadChild={slice.isThreadChildAt(0)} - ignoreMuteFor={ignoreMuteFor} - /> - <FeedItem - key={slice.items[1]._reactKey} - item={slice.items[1]} - isThreadParent={slice.isThreadParentAt(1)} - isThreadChild={slice.isThreadChildAt(1)} - ignoreMuteFor={ignoreMuteFor} - /> - <ViewFullThread slice={slice} /> - <FeedItem - key={slice.items[last]._reactKey} - item={slice.items[last]} - isThreadParent={slice.isThreadParentAt(last)} - isThreadChild={slice.isThreadChildAt(last)} - ignoreMuteFor={ignoreMuteFor} - /> + {slice.items.map((item, i) => ( + <FeedItem + key={item._reactKey} + item={item} + isThreadParent={slice.isThreadParentAt(i)} + isThreadChild={slice.isThreadChildAt(i)} + isThreadLastChild={ + slice.isThreadChildAt(i) && slice.items.length === i + 1 + } + /> + ))} </> ) - } - - return ( - <> - {slice.items.map((item, i) => ( - <FeedItem - key={item._reactKey} - item={item} - isThreadParent={slice.isThreadParentAt(i)} - isThreadChild={slice.isThreadChildAt(i)} - ignoreMuteFor={ignoreMuteFor} - /> - ))} - </> - ) -} + }, +) function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { const pal = usePalette('default') @@ -75,23 +76,28 @@ function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { }, [slice.rootItem.post.uri, slice.rootItem.post.author]) return ( - <Link style={[pal.view, styles.viewFullThread]} href={itemHref} noFeedback> + <Link + style={[pal.view, styles.viewFullThread]} + href={itemHref} + asAnchor + noFeedback> <View style={styles.viewFullThreadDots}> - <Svg width="4" height="30"> + <Svg width="4" height="40"> <Line x1="2" y1="0" x2="2" - y2="8" + y2="15" stroke={pal.colors.replyLine} strokeWidth="2" /> - <Circle cx="2" cy="16" r="1.5" fill={pal.colors.replyLineDot} /> <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} /> <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} /> + <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} /> </Svg> </View> - <Text type="md" style={pal.link}> + + <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> View full thread </Text> </Link> @@ -100,13 +106,12 @@ function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { const styles = StyleSheet.create({ viewFullThread: { - paddingTop: 14, - paddingBottom: 6, - paddingLeft: 80, + flexDirection: 'row', + gap: 10, + paddingLeft: 18, }, viewFullThreadDots: { - position: 'absolute', - left: 41, - top: 0, + width: 52, + alignItems: 'center', }, }) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 946e0f2ab..771785ee9 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,7 +1,11 @@ import * as React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' +import { + AppBskyActorDefs, + moderateProfile, + ProfileModeration, +} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' @@ -11,12 +15,12 @@ import {useStores} from 'state/index' import {FollowButton} from './FollowButton' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import { - getProfileViewBasicLabelInfo, - getProfileModeration, -} from 'lib/labeling/helpers' -import {ModerationBehaviorCode} from 'lib/labeling/types' import {makeProfileLink} from 'lib/routes/links' +import { + describeModerationCause, + getProfileModerationCauses, + getModerationCauseKey, +} from 'lib/moderation' export const ProfileCard = observer( ({ @@ -25,7 +29,6 @@ export const ProfileCard = observer( noBg, noBorder, followers, - overrideModeration, renderButton, }: { testID?: string @@ -33,7 +36,6 @@ export const ProfileCard = observer( noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined - overrideModeration?: boolean renderButton?: ( profile: AppBskyActorDefs.ProfileViewBasic, ) => React.ReactNode @@ -41,18 +43,11 @@ export const ProfileCard = observer( const store = useStores() const pal = usePalette('default') - const moderation = getProfileModeration( - store, - getProfileViewBasicLabelInfo(profile), + const moderation = moderateProfile( + profile, + store.preferences.moderationOpts, ) - if ( - moderation.list.behavior === ModerationBehaviorCode.Hide && - !overrideModeration - ) { - return null - } - return ( <Link testID={testID} @@ -82,20 +77,17 @@ export const ProfileCard = observer( lineHeight={1.2}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, )} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> {sanitizeHandle(profile.handle, '@')} </Text> - {!!profile.viewer?.followedBy && ( - <View style={s.flexRow}> - <View style={[s.mt5, pal.btn, styles.pill]}> - <Text type="xs" style={pal.text}> - Follows You - </Text> - </View> - </View> - )} + <ProfileCardPills + followedBy={!!profile.viewer?.followedBy} + moderation={moderation} + /> + {!!profile.viewer?.followedBy && <View style={s.flexRow} />} </View> {renderButton ? ( <View style={styles.layoutButton}>{renderButton(profile)}</View> @@ -114,6 +106,46 @@ export const ProfileCard = observer( }, ) +function ProfileCardPills({ + followedBy, + moderation, +}: { + followedBy: boolean + moderation: ProfileModeration +}) { + const pal = usePalette('default') + + const causes = getProfileModerationCauses(moderation) + if (!followedBy && !causes.length) { + return null + } + + return ( + <View style={styles.pills}> + {followedBy && ( + <View style={[s.mt5, pal.btn, styles.pill]}> + <Text type="xs" style={pal.text}> + Follows You + </Text> + </View> + )} + {causes.map(cause => { + const desc = describeModerationCause(cause, 'account') + return ( + <View + style={[s.mt5, pal.btn, styles.pill]} + key={getModerationCauseKey(cause)}> + <Text type="xs" style={pal.text}> + {cause?.type === 'label' ? 'âš ' : ''} + {desc.name} + </Text> + </View> + ) + })} + </View> + ) +} + const FollowersList = observer( ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => { const store = useStores() @@ -125,9 +157,9 @@ const FollowersList = observer( const followersWithMods = followers .map(f => ({ f, - mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)), + mod: moderateProfile(f, store.preferences.moderationOpts), })) - .filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide) + .filter(({mod}) => !mod.account.filter) return ( <View style={styles.followedBy}> @@ -218,6 +250,12 @@ const styles = StyleSheet.create({ paddingRight: 10, paddingBottom: 10, }, + pills: { + flexDirection: 'row', + flexWrap: 'wrap', + columnGap: 6, + rowGap: 2, + }, pill: { borderRadius: 4, paddingHorizontal: 6, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index a372f0d81..dd3fb530e 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -21,15 +21,13 @@ import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' import {ThemedText} from '../util/text/ThemedText' -import {TextLink} from '../util/Link' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' -import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings' +import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' -import {listUriToHref} from 'lib/strings/url-helpers' import {isDesktopWeb, isNative} from 'platform/detection' import {FollowState} from 'state/models/cache/my-follows' import {shareUrl} from 'lib/sharing' @@ -116,7 +114,10 @@ const ProfileHeaderLoaded = observer( }, [navigation]) const onPressAvi = React.useCallback(() => { - if (view.avatar) { + if ( + view.avatar && + !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { store.shell.openLightbox(new ProfileImageLightbox(view)) } }, [store, view]) @@ -244,7 +245,7 @@ const ProfileHeaderLoaded = observer( const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal({ - name: 'report-account', + name: 'report', did: view.did, }) }, [track, store, view]) @@ -434,6 +435,7 @@ const ProfileHeaderLoaded = observer( style={[pal.text, styles.title]}> {sanitizeDisplayName( view.displayName || sanitizeHandle(view.handle), + view.moderation.profile, )} </Text> </View> @@ -494,7 +496,9 @@ const ProfileHeaderLoaded = observer( </Text> </Text> </View> - {view.descriptionRichText ? ( + {view.description && + view.descriptionRichText && + !view.moderation.profile.blur ? ( <RichText testID="profileHeaderDescription" style={[styles.description, pal.text]} @@ -504,52 +508,7 @@ const ProfileHeaderLoaded = observer( ) : undefined} </> )} - <ProfileHeaderWarnings moderation={view.moderation.view} /> - <View style={styles.moderationLines}> - {view.viewer.blocking ? ( - <View - testID="profileHeaderBlockedNotice" - style={[styles.moderationNotice, pal.viewLight]}> - <FontAwesomeIcon icon="ban" style={[pal.text]} /> - <Text type="lg-medium" style={pal.text}> - Account blocked - </Text> - </View> - ) : view.viewer.muted ? ( - <View - testID="profileHeaderMutedNotice" - style={[styles.moderationNotice, pal.viewLight]}> - <FontAwesomeIcon - icon={['far', 'eye-slash']} - style={[pal.text]} - /> - <Text type="lg-medium" style={pal.text}> - Account muted{' '} - {view.viewer.mutedByList && ( - <Text type="lg-medium" style={pal.text}> - by{' '} - <TextLink - type="lg-medium" - style={pal.link} - href={listUriToHref(view.viewer.mutedByList.uri)} - text={view.viewer.mutedByList.name} - /> - </Text> - )} - </Text> - </View> - ) : undefined} - {view.viewer.blockedBy && ( - <View - testID="profileHeaderBlockedNotice" - style={[styles.moderationNotice, pal.viewLight]}> - <FontAwesomeIcon icon="ban" style={[pal.text]} /> - <Text type="lg-medium" style={pal.text}> - This account has blocked you - </Text> - </View> - )} - </View> + <ProfileHeaderAlerts moderation={view.moderation} /> </View> {!isDesktopWeb && !hideBackButton && ( <TouchableWithoutFeedback @@ -693,19 +652,6 @@ const styles = StyleSheet.create({ paddingVertical: 2, }, - moderationLines: { - gap: 6, - }, - - moderationNotice: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 8, - paddingHorizontal: 16, - paddingVertical: 14, - gap: 8, - }, - br40: {borderRadius: 40}, br50: {borderRadius: 50}, }) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 2ce499765..bf21ff0d1 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -91,7 +91,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { const styles = StyleSheet.create({ metaOneLine: { flexDirection: 'row', - alignItems: 'baseline', + alignItems: isAndroid ? 'center' : 'baseline', paddingBottom: 2, gap: 4, }, diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index d999ffb31..0f34f75aa 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native' import Svg, {Circle, Rect, Path} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {HighPriorityImage} from 'view/com/util/images/Image' +import {ModerationUI} from '@atproto/api' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import { usePhotoLibraryPermission, @@ -13,7 +14,6 @@ import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' -import {AvatarModeration} from 'lib/labeling/types' import {UserPreviewLink} from './UserPreviewLink' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' @@ -23,7 +23,7 @@ interface BaseUserAvatarProps { type?: Type size: number avatar?: string | null - moderation?: AvatarModeration + moderation?: ModerationUI } interface UserAvatarProps extends BaseUserAvatarProps { @@ -213,20 +213,20 @@ export function UserAvatar({ ], ) - const warning = useMemo(() => { - if (!moderation?.warn) { + const alert = useMemo(() => { + if (!moderation?.alert) { return null } return ( - <View style={[styles.warningIconContainer, pal.view]}> + <View style={[styles.alertIconContainer, pal.view]}> <FontAwesomeIcon icon="exclamation-circle" - style={styles.warningIcon} + style={styles.alertIcon} size={Math.floor(size / 3)} /> </View> ) - }, [moderation?.warn, size, pal]) + }, [moderation?.alert, size, pal]) // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( @@ -259,12 +259,12 @@ export function UserAvatar({ source={{uri: avatar}} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} /> - {warning} + {alert} </View> ) : ( <View style={{width: size, height: size}}> <DefaultAvatar type={type} size={size} /> - {warning} + {alert} </View> ) } @@ -289,13 +289,13 @@ const styles = StyleSheet.create({ justifyContent: 'center', backgroundColor: colors.gray5, }, - warningIconContainer: { + alertIconContainer: { position: 'absolute', right: 0, bottom: 0, borderRadius: 100, }, - warningIcon: { + alertIcon: { color: colors.red3, }, }) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index b7e91b5dd..7c5c583c2 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,6 +1,7 @@ import React, {useMemo} from 'react' import {StyleSheet, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {ModerationUI} from '@atproto/api' import {Image} from 'expo-image' import {colors} from 'lib/styles' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' @@ -10,7 +11,6 @@ import { useCameraPermission, } from 'lib/hooks/usePermissions' import {usePalette} from 'lib/hooks/usePalette' -import {AvatarModeration} from 'lib/labeling/types' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' @@ -21,7 +21,7 @@ export function UserBanner({ onSelectNewBanner, }: { banner?: string | null - moderation?: AvatarModeration + moderation?: ModerationUI onSelectNewBanner?: (img: RNImage | null) => void }) { const store = useStores() diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx index 7eedbc2d4..f43f9e80b 100644 --- a/src/view/com/util/UserPreviewLink.tsx +++ b/src/view/com/util/UserPreviewLink.tsx @@ -2,7 +2,7 @@ import React from 'react' import {Pressable, StyleProp, ViewStyle} from 'react-native' import {useStores} from 'state/index' import {Link} from './Link' -import {isDesktopWeb} from 'platform/detection' +import {isWeb} from 'platform/detection' import {makeProfileLink} from 'lib/routes/links' interface UserPreviewLinkProps { @@ -15,7 +15,7 @@ export function UserPreviewLink( ) { const store = useStores() - if (isDesktopWeb) { + if (isWeb) { return ( <Link href={makeProfileLink(props)} diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index e2f47ba89..a25ca4d8e 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -1,5 +1,11 @@ import React, {useEffect, useState} from 'react' -import {Pressable, RefreshControl, StyleSheet, View} from 'react-native' +import { + Pressable, + RefreshControl, + StyleSheet, + View, + ScrollView, +} from 'react-native' import {FlatList} from './Views' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' @@ -140,6 +146,8 @@ export function Selector({ items: string[] onSelect?: (index: number) => void }) { + const [height, setHeight] = useState(0) + const pal = usePalette('default') const borderColor = useColorSchemeStyle( {borderColor: colors.black}, @@ -151,37 +159,56 @@ export function Selector({ } return ( - <View style={[pal.view, styles.outer]}> - {items.map((item, i) => { - const selected = i === selectedIndex - return ( - <Pressable - testID={`selector-${i}`} - key={item} - onPress={() => onPressItem(i)} - accessibilityLabel={item} - accessibilityHint={`Selects ${item}`} - // TODO: Modify the component API such that lint fails - // at the invocation site as well - > - <View - style={[ - styles.item, - selected && styles.itemSelected, - borderColor, - ]}> - <Text - style={ - selected - ? [styles.labelSelected, pal.text] - : [styles.label, pal.textLight] - }> - {item} - </Text> - </View> - </Pressable> - ) - })} + <View + style={{ + width: '100%', + position: 'relative', + overflow: 'hidden', + height, + backgroundColor: pal.colors.background, + }}> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + style={{position: 'absolute'}}> + <View + style={[pal.view, styles.outer]} + onLayout={e => { + const {height} = e.nativeEvent.layout + setHeight(height || 60) + }}> + {items.map((item, i) => { + const selected = i === selectedIndex + return ( + <Pressable + testID={`selector-${i}`} + key={item} + onPress={() => onPressItem(i)} + accessibilityLabel={item} + accessibilityHint={`Selects ${item}`} + // TODO: Modify the component API such that lint fails + // at the invocation site as well + > + <View + style={[ + styles.item, + selected && styles.itemSelected, + borderColor, + ]}> + <Text + style={ + selected + ? [styles.labelSelected, pal.text] + : [styles.label, pal.textLight] + }> + {item} + </Text> + </View> + </Pressable> + ) + })} + </View> + </ScrollView> </View> ) } diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx index 9e6fcaa44..082285064 100644 --- a/src/view/com/util/forms/NativeDropdown.tsx +++ b/src/view/com/util/forms/NativeDropdown.tsx @@ -60,7 +60,6 @@ export const DropdownMenuTrigger = DropdownMenu.create( icon="ellipsis" size={20} color={defaultCtrlColor} - style={styles.ellipsis} /> )} </View> @@ -252,9 +251,6 @@ const styles = StyleSheet.create({ height: 1, marginVertical: 4, }, - ellipsis: { - padding: isWeb ? 0 : 10, - }, content: { backgroundColor: '#f0f0f0', borderRadius: 8, diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 27a1f20d0..969deb3ac 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,6 +1,9 @@ import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {toShareUrl} from 'lib/strings/url-helpers' import {useStores} from 'state/index' +import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' import { NativeDropdown, @@ -19,6 +22,7 @@ export function PostDropdownBtn({ onOpenTranslate, onToggleThreadMute, onDeletePost, + style, }: { testID: string itemUri: string @@ -31,8 +35,11 @@ export function PostDropdownBtn({ onOpenTranslate: () => void onToggleThreadMute: () => void onDeletePost: () => void + style?: StyleProp<ViewStyle> }) { const store = useStores() + const theme = useTheme() + const defaultCtrlColor = theme.palette.default.postCtrl const dropdownItems: NativeDropdownItem[] = [ { @@ -102,9 +109,9 @@ export function PostDropdownBtn({ label: 'Report post', onPress() { store.shell.openModal({ - name: 'report-post', - postUri: itemUri, - postCid: itemCid, + name: 'report', + uri: itemUri, + cid: itemCid, }) }, testID: 'postDropdownReportBtn', @@ -146,8 +153,11 @@ export function PostDropdownBtn({ testID={testID} items={dropdownItems} accessibilityLabel="More post options" - accessibilityHint="" - /> + accessibilityHint=""> + <View style={style}> + <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} /> + </View> + </NativeDropdown> </EventStopper> ) } diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx index 503c49b2f..4b494264e 100644 --- a/src/view/com/util/forms/SelectableBtn.tsx +++ b/src/view/com/util/forms/SelectableBtn.tsx @@ -5,6 +5,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {isDesktopWeb} from 'platform/detection' interface SelectableBtnProps { + testID?: string selected: boolean label: string left?: boolean @@ -15,6 +16,7 @@ interface SelectableBtnProps { } export function SelectableBtn({ + testID, selected, label, left, @@ -25,12 +27,15 @@ export function SelectableBtn({ }: SelectableBtnProps) { const pal = usePalette('default') const palPrimary = usePalette('inverted') + const needsWidthStyles = !style || !('width' in style || 'flex' in style) return ( <Pressable + testID={testID} style={[ - styles.selectableBtn, - left && styles.selectableBtnLeft, - right && styles.selectableBtnRight, + styles.btn, + needsWidthStyles && styles.btnWidth, + left && styles.btnLeft, + right && styles.btnRight, pal.border, selected ? palPrimary.view : pal.view, style, @@ -45,9 +50,7 @@ export function SelectableBtn({ } const styles = StyleSheet.create({ - selectableBtn: { - flex: isDesktopWeb ? undefined : 1, - width: isDesktopWeb ? 100 : undefined, + btn: { flexDirection: 'row', justifyContent: 'center', borderWidth: 1, @@ -55,12 +58,16 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, paddingVertical: 10, }, - selectableBtnLeft: { + btnWidth: { + flex: isDesktopWeb ? undefined : 1, + width: isDesktopWeb ? 100 : undefined, + }, + btnLeft: { borderTopLeftRadius: 8, borderBottomLeftRadius: 8, borderLeftWidth: 1, }, - selectableBtnRight: { + btnRight: { borderTopRightRadius: 8, borderBottomRightRadius: 8, }, diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index ac5c8395d..853f7840c 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -1,36 +1,32 @@ import React from 'react' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' +import {ModerationUI} from '@atproto/api' import {Text} from '../text/Text' -import {addStyle} from 'lib/styles' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' +import {ShieldExclamation} from 'lib/icons' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' export function ContentHider({ testID, moderation, + ignoreMute, style, - containerStyle, + childContainerStyle, children, }: React.PropsWithChildren<{ testID?: string - moderation: ModerationBehavior + moderation: ModerationUI + ignoreMute?: boolean style?: StyleProp<ViewStyle> - containerStyle?: StyleProp<ViewStyle> + childContainerStyle?: StyleProp<ViewStyle> }>) { + const store = useStores() const pal = usePalette('default') const [override, setOverride] = React.useState(false) - const onPressShow = React.useCallback(() => { - setOverride(true) - }, [setOverride]) - const onPressHide = React.useCallback(() => { - setOverride(false) - }, [setOverride]) - if ( - moderation.behavior === ModerationBehaviorCode.Show || - moderation.behavior === ModerationBehaviorCode.Warn || - moderation.behavior === ModerationBehaviorCode.WarnImages - ) { + if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) { return ( <View testID={testID} style={style}> {children} @@ -38,73 +34,72 @@ export function ContentHider({ ) } - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - + const desc = describeModerationCause(moderation.cause, 'content') return ( - <View style={[styles.container, pal.view, pal.border, containerStyle]}> + <View testID={testID} style={style}> <Pressable - onPress={override ? onPressHide : onPressShow} - accessibilityLabel={override ? 'Hide post' : 'Show post'} - // TODO: The text labelling should be split up so controls have unique roles - accessibilityHint={ - override - ? 'Re-hide post' - : 'Shows post hidden based on your moderation settings' - } + onPress={() => { + if (!moderation.noOverride) { + setOverride(v => !v) + } else { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + } + }} + accessibilityRole="button" + accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityLabel="" style={[ - styles.description, - pal.viewLight, - override && styles.descriptionOpen, + styles.cover, + moderation.noOverride + ? {borderWidth: 1, borderColor: pal.colors.borderDark} + : pal.viewLight, ]}> - <Text type="md" style={pal.textLight}> - {moderation.reason || 'Content warning'} + <Pressable + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint=""> + <ShieldExclamation size={18} style={pal.text} /> + </Pressable> + <Text type="lg" style={pal.text}> + {desc.name} </Text> - <View style={styles.showBtn}> - <Text type="md-medium" style={pal.link}> - {override ? 'Hide' : 'Show'} - </Text> - </View> - </Pressable> - {override && ( - <View style={[styles.childrenContainer, pal.border]}> - <View testID={testID} style={addStyle(style, styles.child)}> - {children} + {!moderation.noOverride && ( + <View style={styles.showBtn}> + <Text type="xl" style={pal.link}> + {override ? 'Hide' : 'Show'} + </Text> </View> - </View> - )} + )} + </Pressable> + {override && <View style={childContainerStyle}>{children}</View>} </View> ) } const styles = StyleSheet.create({ - container: { - marginBottom: 10, - borderWidth: 1, - borderRadius: 12, - }, - description: { + cover: { flexDirection: 'row', alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 4, paddingVertical: 14, paddingLeft: 14, - paddingRight: 18, - borderRadius: 12, - }, - descriptionOpen: { - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - }, - icon: { - marginRight: 10, + paddingRight: isDesktopWeb ? 18 : 22, }, showBtn: { marginLeft: 'auto', + alignSelf: 'center', }, - childrenContainer: { - paddingHorizontal: 12, - paddingTop: 8, - }, - child: {}, }) diff --git a/src/view/com/util/moderation/ImageHider.tsx b/src/view/com/util/moderation/ImageHider.tsx deleted file mode 100644 index 40c9d0a21..000000000 --- a/src/view/com/util/moderation/ImageHider.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../text/Text' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' -import {isDesktopWeb} from 'platform/detection' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' - -export function ImageHider({ - testID, - moderation, - style, - children, -}: React.PropsWithChildren<{ - testID?: string - moderation: ModerationBehavior - style?: StyleProp<ViewStyle> -}>) { - const pal = usePalette('default') - const [override, setOverride] = React.useState(false) - const onPressToggle = React.useCallback(() => { - setOverride(v => !v) - }, [setOverride]) - - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - - if (moderation.behavior !== ModerationBehaviorCode.WarnImages) { - return ( - <View testID={testID} style={style}> - {children} - </View> - ) - } - - return ( - <View testID={testID} style={style}> - <View style={[styles.cover, pal.viewLight]}> - <Pressable - onPress={onPressToggle} - style={[styles.toggleBtn]} - accessibilityLabel="Show image" - accessibilityHint=""> - <FontAwesomeIcon - icon={override ? 'eye' : ['far', 'eye-slash']} - size={24} - style={pal.text as FontAwesomeIconStyle} - /> - <Text type="lg" style={pal.text}> - {moderation.reason || 'Content warning'} - </Text> - <View style={styles.flex1} /> - <Text type="xl-bold" style={pal.link}> - {override ? 'Hide' : 'Show'} - </Text> - </Pressable> - </View> - {override && children} - </View> - ) -} - -const styles = StyleSheet.create({ - cover: { - borderRadius: 8, - marginTop: 4, - }, - toggleBtn: { - flexDirection: 'row', - gap: 8, - alignItems: 'center', - paddingHorizontal: isDesktopWeb ? 24 : 20, - paddingVertical: isDesktopWeb ? 20 : 18, - }, - flex1: { - flex: 1, - }, -}) diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx new file mode 100644 index 000000000..8a6cbbb85 --- /dev/null +++ b/src/view/com/util/moderation/PostAlerts.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {Text} from '../text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {ShieldExclamation} from 'lib/icons' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' + +export function PostAlerts({ + moderation, + includeMute, + style, +}: { + moderation: ModerationUI + includeMute?: boolean + style?: StyleProp<ViewStyle> +}) { + const store = useStores() + const pal = usePalette('default') + + const shouldAlert = + !!moderation.cause && + (moderation.alert || + (includeMute && moderation.blur && moderation.cause?.type === 'muted')) + if (!shouldAlert) { + return null + } + + const desc = describeModerationCause(moderation.cause, 'content') + return ( + <Pressable + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint="" + style={[styles.container, pal.viewLight, style]}> + <ShieldExclamation style={pal.text} size={16} /> + <Text type="lg" style={pal.text}> + {desc.name} + </Text> + <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> + Learn More + </Text> + </Pressable> + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 8, + paddingLeft: 14, + paddingHorizontal: 16, + borderRadius: 8, + }, + learnMoreBtn: { + marginLeft: 'auto', + }, +}) diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index f2b6dbddd..2a52561d4 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -1,17 +1,20 @@ import React, {ComponentProps} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {StyleSheet, Pressable, View} from 'react-native' +import {ModerationUI} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {Link} from '../Link' import {Text} from '../text/Text' import {addStyle} from 'lib/styles' -import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types' +import {describeModerationCause} from 'lib/moderation' +import {ShieldExclamation} from 'lib/icons' +import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' interface Props extends ComponentProps<typeof Link> { // testID?: string // href?: string // style: StyleProp<ViewStyle> - moderation: ModerationBehavior + moderation: ModerationUI } export function PostHider({ @@ -22,60 +25,71 @@ export function PostHider({ children, ...props }: Props) { + const store = useStores() const pal = usePalette('default') const [override, setOverride] = React.useState(false) - const bg = override ? pal.viewLight : pal.view - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - - if (moderation.behavior === ModerationBehaviorCode.Warn) { + if (!moderation.blur) { return ( - <> - <View style={[styles.description, bg, pal.border]}> - <FontAwesomeIcon - icon={['far', 'eye-slash']} - style={[styles.icon, pal.text]} - /> - <Text type="md" style={pal.textLight}> - {moderation.reason || 'Content warning'} - </Text> - <TouchableOpacity - style={styles.showBtn} - onPress={() => setOverride(v => !v)} - accessibilityRole="button"> - <Text type="md" style={pal.link}> - {override ? 'Hide' : 'Show'} post - </Text> - </TouchableOpacity> - </View> - {override && ( - <View style={[styles.childrenContainer, pal.border, bg]}> - <Link - testID={testID} - style={addStyle(style, styles.child)} - href={href} - noFeedback> - {children} - </Link> - </View> - )} - </> + <Link + testID={testID} + style={style} + href={href} + noFeedback + accessible={false} + {...props}> + {children} + </Link> ) } - // NOTE: any further label enforcement should occur in ContentContainer + const desc = describeModerationCause(moderation.cause, 'content') return ( - <Link - testID={testID} - style={style} - href={href} - noFeedback - accessible={false} - {...props}> - {children} - </Link> + <> + <Pressable + onPress={() => { + if (!moderation.noOverride) { + setOverride(v => !v) + } + }} + accessibilityRole="button" + accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityLabel="" + style={[styles.description, pal.viewLight]}> + <Pressable + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint=""> + <ShieldExclamation size={18} style={pal.text} /> + </Pressable> + <Text type="lg" style={pal.text}> + {desc.name} + </Text> + {!moderation.noOverride && ( + <Text type="xl" style={[styles.showBtn, pal.link]}> + {override ? 'Hide' : 'Show'} + </Text> + )} + </Pressable> + {override && ( + <View style={[styles.childrenContainer, pal.border, pal.viewLight]}> + <Link + testID={testID} + style={addStyle(style, styles.child)} + href={href} + noFeedback> + {children} + </Link> + </View> + )} + </> ) } @@ -83,22 +97,23 @@ const styles = StyleSheet.create({ description: { flexDirection: 'row', alignItems: 'center', + gap: 4, paddingVertical: 14, - paddingHorizontal: 18, - borderTopWidth: 1, - }, - icon: { - marginRight: 10, + paddingLeft: 18, + paddingRight: isDesktopWeb ? 18 : 22, + marginTop: 1, }, showBtn: { marginLeft: 'auto', + alignSelf: 'center', }, childrenContainer: { - paddingHorizontal: 6, + paddingHorizontal: 4, paddingBottom: 6, }, child: { - borderWidth: 1, - borderRadius: 12, + borderWidth: 0, + borderTopWidth: 0, + borderRadius: 8, }, }) diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx new file mode 100644 index 000000000..b7781e06d --- /dev/null +++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {ProfileModeration} from '@atproto/api' +import {Text} from '../text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {ShieldExclamation} from 'lib/icons' +import { + describeModerationCause, + getProfileModerationCauses, +} from 'lib/moderation' +import {useStores} from 'state/index' + +export function ProfileHeaderAlerts({ + moderation, + style, +}: { + moderation: ProfileModeration + style?: StyleProp<ViewStyle> +}) { + const store = useStores() + const pal = usePalette('default') + + const causes = getProfileModerationCauses(moderation) + if (!causes.length) { + return null + } + + return ( + <View style={styles.grid}> + {causes.map(cause => { + const desc = describeModerationCause(cause, 'account') + return ( + <Pressable + testID="profileHeaderAlert" + key={desc.name} + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation: {cause}, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint="" + style={[styles.container, pal.viewLight, style]}> + <ShieldExclamation style={pal.text} size={24} /> + <Text type="lg" style={pal.text}> + {desc.name} + </Text> + <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> + Learn More + </Text> + </Pressable> + ) + })} + </View> + ) +} + +const styles = StyleSheet.create({ + grid: { + gap: 4, + }, + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + }, + learnMoreBtn: { + marginLeft: 'auto', + }, +}) diff --git a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx deleted file mode 100644 index 7a1a8e295..000000000 --- a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Text} from '../text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' - -export function ProfileHeaderWarnings({ - moderation, -}: { - moderation: ModerationBehavior -}) { - const palErr = usePalette('error') - if (moderation.behavior === ModerationBehaviorCode.Show) { - return null - } - return ( - <View style={[styles.container, palErr.border, palErr.view]}> - <FontAwesomeIcon - icon="circle-exclamation" - style={palErr.text as FontAwesomeIconStyle} - size={20} - /> - <Text style={palErr.text}> - This account has been flagged: {moderation.reason} - </Text> - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 10, - paddingVertical: 8, - }, -}) diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx index 2e7b07e1a..b76b1101c 100644 --- a/src/view/com/util/moderation/ScreenHider.tsx +++ b/src/view/com/util/moderation/ScreenHider.tsx @@ -1,16 +1,24 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import { + TouchableWithoutFeedback, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' +import {ModerationUI} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {NavigationProp} from 'lib/routes/types' import {Text} from '../text/Text' import {Button} from '../forms/Button' import {isDesktopWeb} from 'platform/detection' -import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' export function ScreenHider({ testID, @@ -22,24 +30,17 @@ export function ScreenHider({ }: React.PropsWithChildren<{ testID?: string screenDescription: string - moderation: ModerationBehavior + moderation: ModerationUI style?: StyleProp<ViewStyle> containerStyle?: StyleProp<ViewStyle> }>) { + const store = useStores() const pal = usePalette('default') const palInverted = usePalette('inverted') const [override, setOverride] = React.useState(false) const navigation = useNavigation<NavigationProp>() - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - if (moderation.behavior !== ModerationBehaviorCode.Hide || override) { + if (!moderation.blur || override) { return ( <View testID={testID} style={style}> {children} @@ -47,6 +48,7 @@ export function ScreenHider({ ) } + const desc = describeModerationCause(moderation.cause, 'account') return ( <View style={[styles.container, pal.view, containerStyle]}> <View style={styles.iconContainer}> @@ -63,11 +65,38 @@ export function ScreenHider({ </Text> <Text type="2xl" style={[styles.description, pal.textLight]}> This {screenDescription} has been flagged:{' '} - {moderation.reason || 'Content warning'} + <Text type="2xl-medium" style={pal.text}> + {desc.name} + </Text> + .{' '} + <TouchableWithoutFeedback + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'account', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint=""> + <Text type="2xl" style={pal.link}> + Learn More + </Text> + </TouchableWithoutFeedback> </Text> {!isDesktopWeb && <View style={styles.spacer} />} <View style={styles.btnContainer}> - <Button type="inverted" onPress={onPressBack} style={styles.btn}> + <Button + type="inverted" + onPress={() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }} + style={styles.btn}> <Text type="button-lg" style={pal.textInverted}> Go back </Text> diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 672e02693..c71100df0 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,11 +6,6 @@ import { View, ViewStyle, } from 'react-native' -// DISABLED see #135 -// import { -// TriggerableAnimated, -// TriggerableAnimatedRef, -// } from './anim/TriggerableAnimated' import {Text} from '../text/Text' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' @@ -20,7 +15,6 @@ import {useTheme} from 'lib/ThemeContext' import {useStores} from 'state/index' import {RepostButton} from './RepostButton' import {Haptics} from 'lib/haptics' -import {createHitslop} from 'lib/constants' interface PostCtrlsOpts { itemUri: string @@ -53,44 +47,6 @@ interface PostCtrlsOpts { onDeletePost: () => void } -const HITSLOP = createHitslop(5) - -// DISABLED see #135 -/* -function ctrlAnimStart(interp: Animated.Value) { - return Animated.sequence([ - Animated.timing(interp, { - toValue: 1, - duration: 250, - useNativeDriver: true, - }), - Animated.delay(50), - Animated.timing(interp, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]) -} - -function ctrlAnimStyle(interp: Animated.Value) { - return { - transform: [ - { - scale: interp.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 4.0], - }), - }, - ], - opacity: interp.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 0.0], - }), - } -} -*/ - export function PostCtrls(opts: PostCtrlsOpts) { const store = useStores() const theme = useTheme() @@ -100,22 +56,11 @@ export function PostCtrls(opts: PostCtrlsOpts) { }), [theme], ) as StyleProp<ViewStyle> - // DISABLED see #135 - // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null) - // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null) const onRepost = useCallback(() => { store.shell.closeModal() if (!opts.isReposted) { Haptics.default() opts.onPressToggleRepost().catch(_e => undefined) - // DISABLED see #135 - // repostRef.current?.trigger( - // {start: ctrlAnimStart, style: ctrlAnimStyle}, - // async () => { - // await opts.onPressToggleRepost().catch(_e => undefined) - // setRepostMod(0) - // }, - // ) } else { opts.onPressToggleRepost().catch(_e => undefined) } @@ -146,18 +91,8 @@ export function PostCtrls(opts: PostCtrlsOpts) { if (!opts.isLiked) { Haptics.default() await opts.onPressToggleLike().catch(_e => undefined) - // DISABLED see #135 - // likeRef.current?.trigger( - // {start: ctrlAnimStart, style: ctrlAnimStyle}, - // async () => { - // await opts.onPressToggleLike().catch(_e => undefined) - // setLikeMod(0) - // }, - // ) - // setIsLikedPressed(false) } else { await opts.onPressToggleLike().catch(_e => undefined) - // setIsLikedPressed(false) } } @@ -165,8 +100,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <View style={[styles.ctrls, opts.style]}> <TouchableOpacity testID="replyBtn" - style={styles.ctrl} - hitSlop={HITSLOP} + style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]} onPress={opts.onPressReply} accessibilityRole="button" accessibilityLabel={`Reply (${opts.replyCount} ${ @@ -187,8 +121,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} /> <TouchableOpacity testID="likeBtn" - style={styles.ctrl} - hitSlop={HITSLOP} + style={[styles.ctrl, !opts.big && styles.ctrlPad]} onPress={onPressToggleLikeWrapper} accessibilityRole="button" accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${ @@ -232,6 +165,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { onOpenTranslate={opts.onOpenTranslate} onToggleThreadMute={opts.onToggleThreadMute} onDeletePost={opts.onDeletePost} + style={styles.ctrlPad} /> )} {/* used for adding pad to the right side */} @@ -248,8 +182,12 @@ const styles = StyleSheet.create({ ctrl: { flexDirection: 'row', alignItems: 'center', - padding: 5, - margin: -5, + }, + ctrlPad: { + paddingTop: 5, + paddingBottom: 5, + paddingLeft: 5, + paddingRight: 5, }, ctrlIconLiked: { color: colors.like, diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 5fe62aefe..374d06515 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -6,9 +6,6 @@ import {useTheme} from 'lib/ThemeContext' import {Text} from '../text/Text' import {pluralize} from 'lib/strings/helpers' import {useStores} from 'state/index' -import {createHitslop} from 'lib/constants' - -const HITSLOP = createHitslop(5) interface Props { isReposted: boolean @@ -47,9 +44,8 @@ export const RepostButton = ({ return ( <TouchableOpacity testID="repostBtn" - hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} - style={styles.control} + style={[styles.control, !big && styles.controlPad]} accessibilityRole="button" accessibilityLabel={`${ isReposted ? 'Undo repost' : 'Repost' @@ -83,8 +79,9 @@ const styles = StyleSheet.create({ control: { flexDirection: 'row', alignItems: 'center', + }, + controlPad: { padding: 5, - margin: -5, }, reposted: { color: colors.green3, diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 4d2a3fcdd..eab6e2fef 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -52,6 +52,7 @@ export const RepostButton = ({ <View style={[ styles.control, + !big && styles.controlPad, (isReposted ? styles.reposted : defaultControlColor) as StyleProp<ViewStyle>, @@ -77,6 +78,9 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 4, }, + controlPad: { + padding: 5, + }, reposted: { color: colors.green3, }, diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index a4cbb3e29..81f1ca560 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -1,9 +1,11 @@ import React from 'react' +import {Image} from 'expo-image' import {Text} from '../text/Text' -import {AutoSizedImage} from '../images/AutoSizedImage' import {StyleSheet, View} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' import {AppBskyEmbedExternal} from '@atproto/api' +import {isDesktopWeb} from 'platform/detection' +import {toNiceDomain} from 'lib/strings/url-helpers' export const ExternalLinkEmbed = ({ link, @@ -14,44 +16,71 @@ export const ExternalLinkEmbed = ({ }) => { const pal = usePalette('default') return ( - <> + <View style={styles.extContainer}> {link.thumb ? ( - <AutoSizedImage uri={link.thumb} style={styles.extImage}> + <View style={styles.extImageContainer}> + <Image + style={styles.extImage} + source={{uri: link.thumb}} + accessibilityIgnoresInvertColors + /> {imageChild} - </AutoSizedImage> + </View> ) : undefined} <View style={styles.extInner}> - <Text type="md-bold" numberOfLines={2} style={[pal.text]}> - {link.title || link.uri} - </Text> <Text type="sm" numberOfLines={1} style={[pal.textLight, styles.extUri]}> - {link.uri} + {toNiceDomain(link.uri)} + </Text> + <Text + type="lg-bold" + numberOfLines={isDesktopWeb ? 2 : 4} + style={[pal.text]}> + {link.title || link.uri} </Text> {link.description ? ( <Text - type="sm" - numberOfLines={2} + type="md" + numberOfLines={isDesktopWeb ? 2 : 4} style={[pal.text, styles.extDescription]}> {link.description} </Text> ) : undefined} </View> - </> + </View> ) } const styles = StyleSheet.create({ + extContainer: { + flexDirection: isDesktopWeb ? 'row' : 'column', + }, extInner: { - padding: 10, + paddingHorizontal: isDesktopWeb ? 14 : 10, + paddingTop: 8, + paddingBottom: 10, + flex: isDesktopWeb ? 1 : undefined, }, + extImageContainer: isDesktopWeb + ? { + borderTopLeftRadius: 6, + borderBottomLeftRadius: 6, + width: 120, + aspectRatio: 1, + overflow: 'hidden', + } + : { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + height: 200, + overflow: 'hidden', + }, extImage: { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, width: '100%', - maxHeight: 200, + height: 200, }, extUri: { marginTop: 2, diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 4995562ac..f82b5b7df 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -1,6 +1,12 @@ import React from 'react' -import {StyleProp, StyleSheet, ViewStyle} from 'react-native' -import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import { + AppBskyEmbedRecord, + AppBskyFeedPost, + AppBskyEmbedImages, + AppBskyEmbedRecordWithMedia, + ModerationUI, +} from '@atproto/api' import {AtUri} from '@atproto/api' import {PostMeta} from '../PostMeta' import {Link} from '../Link' @@ -8,13 +14,68 @@ import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ComposerOptsQuote} from 'state/models/ui/shell' import {PostEmbeds} from '.' +import {PostAlerts} from '../moderation/PostAlerts' import {makeProfileLink} from 'lib/routes/links' +import {InfoCircleIcon} from 'lib/icons' + +export function MaybeQuoteEmbed({ + embed, + moderation, + style, +}: { + embed: AppBskyEmbedRecord.View + moderation: ModerationUI + style?: StyleProp<ViewStyle> +}) { + const pal = usePalette('default') + if ( + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) && + AppBskyFeedPost.validateRecord(embed.record.value).success + ) { + return ( + <QuoteEmbed + quote={{ + author: embed.record.author, + cid: embed.record.cid, + uri: embed.record.uri, + indexedAt: embed.record.indexedAt, + text: embed.record.value.text, + embeds: embed.record.embeds, + }} + moderation={moderation} + style={style} + /> + ) + } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { + return ( + <View style={[styles.errorContainer, pal.borderDark]}> + <InfoCircleIcon size={18} style={pal.text} /> + <Text type="lg" style={pal.text}> + Blocked + </Text> + </View> + ) + } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { + return ( + <View style={[styles.errorContainer, pal.borderDark]}> + <InfoCircleIcon size={18} style={pal.text} /> + <Text type="lg" style={pal.text}> + Deleted + </Text> + </View> + ) + } + return null +} export function QuoteEmbed({ quote, + moderation, style, }: { quote: ComposerOptsQuote + moderation?: ModerationUI style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -46,16 +107,19 @@ export function QuoteEmbed({ postHref={itemHref} timestamp={quote.indexedAt} /> + {moderation ? ( + <PostAlerts moderation={moderation} style={styles.alert} /> + ) : null} {!isEmpty ? ( <Text type="post-text" style={pal.text} numberOfLines={6}> {quote.text} </Text> ) : null} {AppBskyEmbedImages.isView(imagesEmbed) && ( - <PostEmbeds embed={imagesEmbed} /> + <PostEmbeds embed={imagesEmbed} moderation={{}} /> )} {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && ( - <PostEmbeds embed={imagesEmbed.media} /> + <PostEmbeds embed={imagesEmbed.media} moderation={{}} /> )} </Link> ) @@ -76,4 +140,17 @@ const styles = StyleSheet.create({ paddingLeft: 13, paddingRight: 8, }, + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 8, + paddingVertical: 14, + paddingHorizontal: 14, + borderWidth: 1, + }, + alert: { + marginBottom: 6, + }, }) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 7ffebff54..5d0090434 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -4,17 +4,18 @@ import { StyleProp, View, ViewStyle, - Image as RNImage, Text, + InteractionManager, } from 'react-native' +import {Image} from 'expo-image' import { AppBskyEmbedImages, AppBskyEmbedExternal, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, - AppBskyFeedPost, AppBskyFeedDefs, AppBskyGraphDefs, + ModerationUI, } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' @@ -24,11 +25,12 @@ import {usePalette} from 'lib/hooks/usePalette' import {YoutubeEmbed} from './YoutubeEmbed' import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' -import QuoteEmbed from './QuoteEmbed' +import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' import {CustomFeedEmbed} from './CustomFeedEmbed' import {ListEmbed} from './ListEmbed' import {isDesktopWeb} from 'platform/detection' +import {isCauseALabelOnUri} from 'lib/moderation' type Embed = | AppBskyEmbedRecord.View @@ -39,9 +41,11 @@ type Embed = export function PostEmbeds({ embed, + moderation, style, }: { embed?: Embed + moderation: ModerationUI style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -49,51 +53,37 @@ export function PostEmbeds({ // quote post with media // = - if ( - AppBskyEmbedRecordWithMedia.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record.record) && - AppBskyFeedPost.isRecord(embed.record.record.value) && - AppBskyFeedPost.validateRecord(embed.record.record.value).success - ) { + if (AppBskyEmbedRecordWithMedia.isView(embed)) { + const isModOnQuote = + AppBskyEmbedRecord.isViewRecord(embed.record.record) && + isCauseALabelOnUri(moderation.cause, embed.record.record.uri) + const mediaModeration = isModOnQuote ? {} : moderation + const quoteModeration = isModOnQuote ? moderation : {} return ( <View style={[styles.stackContainer, style]}> - <PostEmbeds embed={embed.media} /> - <QuoteEmbed - quote={{ - author: embed.record.record.author, - cid: embed.record.record.cid, - uri: embed.record.record.uri, - indexedAt: embed.record.record.indexedAt, - text: embed.record.record.value.text, - embeds: embed.record.record.embeds, - }} - /> + <PostEmbeds embed={embed.media} moderation={mediaModeration} /> + <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> </View> ) } - // quote post - // = if (AppBskyEmbedRecord.isView(embed)) { - if ( - AppBskyEmbedRecord.isViewRecord(embed.record) && - AppBskyFeedPost.isRecord(embed.record.value) && - AppBskyFeedPost.validateRecord(embed.record.value).success - ) { - return ( - <QuoteEmbed - quote={{ - author: embed.record.author, - cid: embed.record.cid, - uri: embed.record.uri, - indexedAt: embed.record.indexedAt, - text: embed.record.value.text, - embeds: embed.record.embeds, - }} - style={style} - /> - ) + // custom feed embed (i.e. generator view) + // = + if (AppBskyFeedDefs.isGeneratorView(embed.record)) { + return <CustomFeedEmbed record={embed.record} /> } + + // list embed (e.g. mute lists; i.e. ListView) + if (AppBskyGraphDefs.isListView(embed.record)) { + return <ListEmbed item={embed.record} /> + } + + // quote post + // = + return ( + <MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} /> + ) } // image embed @@ -106,14 +96,9 @@ export function PostEmbeds({ const openLightbox = (index: number) => { store.shell.openLightbox(new ImagesLightbox(items, index)) } - const onPressIn = (index: number) => { - const firstImageToShow = items[index].uri - RNImage.prefetch(firstImageToShow) - items.forEach(item => { - if (firstImageToShow !== item.uri) { - // First image already prefetched above - RNImage.prefetch(item.uri) - } + const onPressIn = (_: number) => { + InteractionManager.runAfterInteractions(() => { + Image.prefetch(items.map(i => i.uri)) }) } @@ -152,23 +137,6 @@ export function PostEmbeds({ } } - // custom feed embed (i.e. generator view) - // = - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyFeedDefs.isGeneratorView(embed.record) - ) { - return <CustomFeedEmbed record={embed.record} /> - } - - // list embed (e.g. mute lists; i.e. ListView) - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyGraphDefs.isListView(embed.record) - ) { - return <ListEmbed item={embed.record} /> - } - // external link embed // = if (AppBskyEmbedExternal.isView(embed)) { |