diff options
author | Minseo Lee <itoupluk427@gmail.com> | 2024-03-19 10:52:29 +0900 |
---|---|---|
committer | Minseo Lee <itoupluk427@gmail.com> | 2024-03-19 10:52:29 +0900 |
commit | ad43d594c9f63fc85e6927d23cd3f3f21406b002 (patch) | |
tree | 8a20f9f9051ff066bd54c5bc126ccc548e2cb16c /src/view | |
parent | 73dae9f7b5c169aa303e9ef9487040e850998edf (diff) | |
parent | 3abf302b0b189c50acf11489bf60bdaeb187b722 (diff) | |
download | voidsky-ad43d594c9f63fc85e6927d23cd3f3f21406b002.tar.zst |
Merge remote-tracking branch 'upstream/main' into patch-3
Diffstat (limited to 'src/view')
53 files changed, 1589 insertions, 3348 deletions
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts index 7a727ec0b..840084dcb 100644 --- a/src/view/com/auth/create/state.ts +++ b/src/view/com/auth/create/state.ts @@ -12,7 +12,7 @@ import {createFullHandle, validateHandle} from '#/lib/strings/handles' import {cleanError} from '#/lib/strings/errors' import {useOnboardingDispatch} from '#/state/shell/onboarding' import {useSessionApi} from '#/state/session' -import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants' +import {DEFAULT_SERVICE, IS_TEST_USER} from '#/lib/constants' import { DEFAULT_PROD_FEEDS, usePreferencesSetBirthDateMutation, @@ -147,7 +147,7 @@ export function useSubmitCreateAccount( : undefined, }) setBirthDate({birthDate: uiState.birthDate}) - if (IS_PROD_SERVICE(uiState.serviceUrl)) { + if (!IS_TEST_USER(uiState.handle)) { setSavedFeeds(DEFAULT_PROD_FEEDS) } } catch (e: any) { diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 3023ac6c3..dba3f8c56 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -1,6 +1,6 @@ import React from 'react' import {View, StyleSheet, ActivityIndicator} from 'react-native' -import {ProfileModeration, AppBskyActorDefs} from '@atproto/api' +import {ModerationDecision, AppBskyActorDefs} from '@atproto/api' import {Button} from '#/view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -19,7 +19,7 @@ import {logger} from '#/logger' type Props = { profile: AppBskyActorDefs.ProfileViewBasic - moderation: ProfileModeration + moderation: ModerationDecision onFollowStateChange: (props: { did: string following: boolean @@ -63,7 +63,7 @@ function ProfileCard({ moderation, }: { profile: Shadow<AppBskyActorDefs.ProfileViewBasic> - moderation: ProfileModeration + moderation: ModerationDecision onFollowStateChange: (props: { did: string following: boolean @@ -115,7 +115,7 @@ function ProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> @@ -126,7 +126,7 @@ function ProfileCard({ lineHeight={1.2}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text type="xl" style={[pal.textLight]} numberOfLines={1}> diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 97f8e5194..0a2692d06 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -39,7 +39,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useExternalLinkFetch} from './useExternalLinkFetch' import {isWeb, isNative, isAndroid, isIOS} from 'platform/detection' -import QuoteEmbed from '../util/post-embeds/QuoteEmbed' +import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed' import {GalleryModel} from 'state/models/media/gallery' import {Gallery} from './photos/Gallery' import {MAX_GRAPHEME_LENGTH} from 'lib/constants' diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 39a1473a3..4832bca02 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -15,7 +15,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {UserAvatar} from 'view/com/util/UserAvatar' import {Text} from 'view/com/util/text/Text' -import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed' +import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed' export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { const pal = usePalette('default') @@ -86,7 +86,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { <UserAvatar avatar={replyTo.author.avatar} size={50} - moderation={replyTo.moderation?.avatar} + moderation={replyTo.moderation?.ui('avatar')} /> <View style={styles.replyToPost}> <Text type="xl-medium" style={[pal.text]}> @@ -103,7 +103,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { {replyTo.text} </Text> </View> - {images && !replyTo.moderation?.embed.blur && ( + {images && !replyTo.moderation?.ui('contentMedia').blur && ( <ComposerReplyToImages images={images} showFull={showFull} /> )} </View> diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx index 78b1e9ba2..785622225 100644 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ b/src/view/com/composer/select-language/SelectLangBtn.tsx @@ -20,7 +20,7 @@ import { toPostLanguages, hasPostLanguage, } from '#/state/preferences/languages' -import {t, msg} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' export function SelectLangBtn() { @@ -84,15 +84,15 @@ export function SelectLangBtn() { } return [ - {heading: true, label: t`Post language`}, + {heading: true, label: _(msg`Post language`)}, ...arr.slice(0, 6), {sep: true}, { - label: t`Other...`, + label: _(msg`Other...`), onPress: onPressMore, }, ] - }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref]) + }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _]) return ( <DropdownButton diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index 3401adaff..3872919de 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -6,9 +6,11 @@ * */ import React from 'react' -import {createHitslop} from 'lib/constants' import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native' -import {t} from '@lingui/macro' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {createHitslop} from '#/lib/constants' type Props = { onRequestClose: () => void @@ -16,20 +18,23 @@ type Props = { const HIT_SLOP = createHitslop(16) -const ImageDefaultHeader = ({onRequestClose}: Props) => ( - <SafeAreaView style={styles.root}> - <TouchableOpacity - style={styles.closeButton} - onPress={onRequestClose} - hitSlop={HIT_SLOP} - accessibilityRole="button" - accessibilityLabel={t`Close image`} - accessibilityHint={t`Closes viewer for header image`} - onAccessibilityEscape={onRequestClose}> - <Text style={styles.closeText}>✕</Text> - </TouchableOpacity> - </SafeAreaView> -) +const ImageDefaultHeader = ({onRequestClose}: Props) => { + const {_} = useLingui() + return ( + <SafeAreaView style={styles.root}> + <TouchableOpacity + style={styles.closeButton} + onPress={onRequestClose} + hitSlop={HIT_SLOP} + accessibilityRole="button" + accessibilityLabel={_(msg`Close image`)} + accessibilityHint={_(msg`Closes viewer for header image`)} + onAccessibilityEscape={onRequestClose}> + <Text style={styles.closeText}>✕</Text> + </TouchableOpacity> + </SafeAreaView> + ) +} const styles = StyleSheet.create({ root: { diff --git a/src/view/com/modals/AppealLabel.tsx b/src/view/com/modals/AppealLabel.tsx deleted file mode 100644 index b0aaaf625..000000000 --- a/src/view/com/modals/AppealLabel.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, {useState} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {ComAtprotoModerationDefs} from '@atproto/api' -import {ScrollView, TextInput} from './util' -import {Text} from '../util/text/Text' -import {s, colors} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {CharProgress} from '../composer/char-progress/CharProgress' -import {getAgent} from '#/state/session' -import * as Toast from '../util/Toast' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' - -export const snapPoints = ['40%'] - -type ReportComponentProps = - | { - uri: string - cid: string - } - | { - did: string - } - -export function Component(props: ReportComponentProps) { - const pal = usePalette('default') - const [details, setDetails] = useState<string>('') - const {_} = useLingui() - const {closeModal} = useModalControls() - const {isMobile} = useWebMediaQueries() - const isAccountReport = 'did' in props - - const submit = async () => { - try { - const $type = !isAccountReport - ? 'com.atproto.repo.strongRef' - : 'com.atproto.admin.defs#repoRef' - await getAgent().createModerationReport({ - reasonType: ComAtprotoModerationDefs.REASONAPPEAL, - subject: { - $type, - ...props, - }, - reason: details, - }) - Toast.show(_(msg`We'll look into your appeal promptly.`)) - } finally { - closeModal() - } - } - - return ( - <View - style={[ - pal.view, - s.flex1, - isMobile ? {paddingHorizontal: 12} : undefined, - ]} - testID="appealLabelModal"> - <Text - type="2xl-bold" - style={[pal.text, s.textCenter, {paddingBottom: 8}]}> - <Trans>Appeal Content Warning</Trans> - </Text> - <ScrollView> - <View style={[pal.btn, styles.detailsInputContainer]}> - <TextInput - accessibilityLabel={_(msg`Text input field`)} - accessibilityHint={_( - msg`Please tell us why you think this content warning was incorrectly applied!`, - )} - placeholder={_( - msg`Please tell us why you think this content warning was incorrectly applied!`, - )} - placeholderTextColor={pal.textLight.color} - value={details} - onChangeText={setDetails} - autoFocus={true} - numberOfLines={3} - multiline={true} - textAlignVertical="top" - maxLength={300} - style={[styles.detailsInput, pal.text]} - /> - <View style={styles.detailsInputBottomBar}> - <View style={styles.charCounter}> - <CharProgress count={details?.length || 0} /> - </View> - </View> - </View> - <TouchableOpacity - testID="confirmBtn" - onPress={submit} - style={styles.btn} - accessibilityRole="button" - accessibilityLabel={_(msg`Confirm`)} - accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}> - <Trans>Submit</Trans> - </Text> - </TouchableOpacity> - </ScrollView> - </View> - ) -} - -const styles = StyleSheet.create({ - detailsInputContainer: { - borderRadius: 8, - marginBottom: 8, - }, - detailsInput: { - paddingHorizontal: 12, - paddingTop: 12, - paddingBottom: 12, - borderRadius: 8, - minHeight: 100, - fontSize: 16, - }, - detailsInputBottomBar: { - alignSelf: 'flex-end', - }, - charCounter: { - flexDirection: 'row', - alignItems: 'center', - paddingRight: 10, - paddingBottom: 8, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - backgroundColor: colors.blue3, - }, -}) diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx deleted file mode 100644 index 3c7edcf0d..000000000 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import React from 'react' -import {LabelPreference} from '@atproto/api' -import {StyleSheet, Pressable, View, Linking} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import {ScrollView} from './util' -import {s, colors, gradients} from 'lib/styles' -import {Text} from '../util/text/Text' -import {TextLink} from '../util/Link' -import {ToggleButton} from '../util/forms/ToggleButton' -import {Button} from '../util/forms/Button' -import {usePalette} from 'lib/hooks/usePalette' -import {isIOS} from 'platform/detection' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import * as Toast from '../util/Toast' -import {logger} from '#/logger' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import { - usePreferencesQuery, - usePreferencesSetContentLabelMutation, - usePreferencesSetAdultContentMutation, - ConfigurableLabelGroup, - CONFIGURABLE_LABEL_GROUPS, - UsePreferencesQueryResponse, -} from '#/state/queries/preferences' -import {useDialogControl} from '#/components/Dialog' -import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' - -export const snapPoints = ['90%'] - -export function Component({}: {}) { - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') - const {_} = useLingui() - const {closeModal} = useModalControls() - const {data: preferences} = usePreferencesQuery() - - const onPressDone = React.useCallback(() => { - closeModal() - }, [closeModal]) - - return ( - <View testID="contentFilteringModal" style={[pal.view, styles.container]}> - <Text style={[pal.text, styles.title]}> - <Trans>Content Filtering</Trans> - </Text> - - <ScrollView style={styles.scrollContainer}> - <AdultContentEnabledPref /> - <ContentLabelPref - preferences={preferences} - labelGroup="nsfw" - disabled={!preferences?.adultContentEnabled} - /> - <ContentLabelPref - preferences={preferences} - labelGroup="nudity" - disabled={!preferences?.adultContentEnabled} - /> - <ContentLabelPref - preferences={preferences} - labelGroup="suggestive" - disabled={!preferences?.adultContentEnabled} - /> - <ContentLabelPref - preferences={preferences} - labelGroup="gore" - disabled={!preferences?.adultContentEnabled} - /> - <ContentLabelPref preferences={preferences} labelGroup="hate" /> - <ContentLabelPref preferences={preferences} labelGroup="spam" /> - <ContentLabelPref - preferences={preferences} - labelGroup="impersonation" - /> - <View style={{height: isMobile ? 60 : 0}} /> - </ScrollView> - - <View - style={[ - styles.btnContainer, - isMobile && styles.btnContainerMobile, - pal.borderDark, - ]}> - <Pressable - testID="sendReportBtn" - onPress={onPressDone} - accessibilityRole="button" - accessibilityLabel={_(msg`Done`)} - accessibilityHint=""> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}> - <Trans>Done</Trans> - </Text> - </LinearGradient> - </Pressable> - </View> - </View> - ) -} - -function AdultContentEnabledPref() { - const pal = usePalette('default') - const {_} = useLingui() - const {data: preferences} = usePreferencesQuery() - const {mutate, variables} = usePreferencesSetAdultContentMutation() - const bithdayDialogControl = useDialogControl() - - const onSetAge = React.useCallback( - () => bithdayDialogControl.open(), - [bithdayDialogControl], - ) - - const onToggleAdultContent = React.useCallback(async () => { - if (isIOS) return - - try { - mutate({ - enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), - }) - } catch (e) { - Toast.show( - _(msg`There was an issue syncing your preferences with the server`), - ) - logger.error('Failed to update preferences with server', {message: e}) - } - }, [variables, preferences, mutate, _]) - - const onAdultContentLinkPress = React.useCallback(() => { - Linking.openURL('https://bsky.app/') - }, []) - - return ( - <View style={s.mb10}> - <BirthDateSettingsDialog - control={bithdayDialogControl} - preferences={preferences} - /> - {isIOS ? ( - preferences?.adultContentEnabled ? null : ( - <Text type="md" style={pal.textLight}> - <Trans> - Adult content can only be enabled via the Web at{' '} - <TextLink - style={pal.link} - href="" - text="bsky.app" - onPress={onAdultContentLinkPress} - /> - . - </Trans> - </Text> - ) - ) : typeof preferences?.birthDate === 'undefined' ? ( - <View style={[pal.viewLight, styles.agePrompt]}> - <Text type="md" style={[pal.text, {flex: 1}]}> - <Trans>Confirm your age to enable adult content.</Trans> - </Text> - <Button - type="primary" - label={_(msg({message: 'Set Age', context: 'action'}))} - onPress={onSetAge} - /> - </View> - ) : (preferences.userAge || 0) >= 18 ? ( - <ToggleButton - type="default-light" - label={_(msg`Enable Adult Content`)} - isSelected={variables?.enabled ?? preferences?.adultContentEnabled} - onPress={onToggleAdultContent} - style={styles.toggleBtn} - /> - ) : ( - <View style={[pal.viewLight, styles.agePrompt]}> - <Text type="md" style={[pal.text, {flex: 1}]}> - <Trans>You must be 18 or older to enable adult content.</Trans> - </Text> - <Button - type="primary" - label={_(msg({message: 'Set Age', context: 'action'}))} - onPress={onSetAge} - /> - </View> - )} - </View> - ) -} - -// TODO: Refactor this component to pass labels down to each tab -function ContentLabelPref({ - preferences, - labelGroup, - disabled, -}: { - preferences?: UsePreferencesQueryResponse - labelGroup: ConfigurableLabelGroup - disabled?: boolean -}) { - const pal = usePalette('default') - const visibility = preferences?.contentLabels?.[labelGroup] - const {mutate, variables} = usePreferencesSetContentLabelMutation() - - const onChange = React.useCallback( - (vis: LabelPreference) => { - mutate({labelGroup, visibility: vis}) - }, - [mutate, labelGroup], - ) - - return ( - <View style={[styles.contentLabelPref, pal.border]}> - <View style={s.flex1}> - <Text type="md-medium" style={[pal.text]}> - {CONFIGURABLE_LABEL_GROUPS[labelGroup].title} - </Text> - {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && ( - <Text type="sm" style={[pal.textLight]}> - {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle} - </Text> - )} - </View> - - {disabled || !visibility ? ( - <Text type="sm-bold" style={pal.textLight}> - <Trans context="action">Hide</Trans> - </Text> - ) : ( - <SelectGroup - current={variables?.visibility || visibility} - onChange={onChange} - labelGroup={labelGroup} - /> - )} - </View> - ) -} - -interface SelectGroupProps { - current: LabelPreference - onChange: (v: LabelPreference) => void - labelGroup: ConfigurableLabelGroup -} - -function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) { - const {_} = useLingui() - - return ( - <View style={styles.selectableBtns}> - <SelectableBtn - current={current} - value="hide" - label={_(msg`Hide`)} - left - onChange={onChange} - labelGroup={labelGroup} - /> - <SelectableBtn - current={current} - value="warn" - label={_(msg`Warn`)} - onChange={onChange} - labelGroup={labelGroup} - /> - <SelectableBtn - current={current} - value="ignore" - label={_(msg`Show`)} - right - onChange={onChange} - labelGroup={labelGroup} - /> - </View> - ) -} - -interface SelectableBtnProps { - current: string - value: LabelPreference - label: string - left?: boolean - right?: boolean - onChange: (v: LabelPreference) => void - labelGroup: ConfigurableLabelGroup -} - -function SelectableBtn({ - current, - value, - label, - left, - right, - onChange, - labelGroup, -}: SelectableBtnProps) { - const pal = usePalette('default') - const palPrimary = usePalette('inverted') - const {_} = useLingui() - - return ( - <Pressable - style={[ - styles.selectableBtn, - left && styles.selectableBtnLeft, - right && styles.selectableBtnRight, - pal.border, - current === value ? palPrimary.view : pal.view, - ]} - onPress={() => onChange(value)} - accessibilityRole="button" - accessibilityLabel={value} - accessibilityHint={_( - msg`Set ${value} for ${labelGroup} content moderation policy`, - )}> - <Text style={current === value ? palPrimary.text : pal.text}> - {label} - </Text> - </Pressable> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - fontSize: 24, - marginBottom: 12, - }, - description: { - paddingHorizontal: 2, - marginBottom: 10, - }, - scrollContainer: { - flex: 1, - paddingHorizontal: 10, - }, - btnContainer: { - paddingTop: 10, - paddingHorizontal: 10, - }, - btnContainerMobile: { - paddingBottom: 40, - borderTopWidth: 1, - }, - - agePrompt: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingLeft: 14, - paddingRight: 10, - paddingVertical: 8, - borderRadius: 8, - }, - - contentLabelPref: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingTop: 14, - paddingLeft: 4, - marginBottom: 14, - borderTopWidth: 1, - }, - - selectableBtns: { - flexDirection: 'row', - marginLeft: 10, - }, - selectableBtn: { - flexDirection: 'row', - justifyContent: 'center', - borderWidth: 1, - borderLeftWidth: 0, - paddingHorizontal: 10, - paddingVertical: 10, - }, - selectableBtnLeft: { - borderTopLeftRadius: 8, - borderBottomLeftRadius: 8, - borderLeftWidth: 1, - }, - selectableBtnRight: { - borderTopRightRadius: 8, - borderBottomRightRadius: 8, - }, - - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 14, - backgroundColor: colors.gray1, - }, - toggleBtn: { - paddingHorizontal: 0, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index e382e6fab..238cfc502 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -15,16 +15,12 @@ import * as UserAddRemoveListsModal from './UserAddRemoveLists' import * as ListAddUserModal from './ListAddRemoveUsers' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' -import * as ReportModal from './report/Modal' -import * as AppealLabelModal from './AppealLabel' import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' -import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' -import * as ModerationDetailsModal from './ModerationDetails' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as ChangePasswordModal from './ChangePassword' @@ -67,12 +63,6 @@ export function ModalsContainer() { if (activeModal?.name === 'edit-profile') { snapPoints = EditProfileModal.snapPoints element = <EditProfileModal.Component {...activeModal} /> - } else if (activeModal?.name === 'report') { - snapPoints = ReportModal.snapPoints - element = <ReportModal.Component {...activeModal} /> - } else if (activeModal?.name === 'appeal-label') { - snapPoints = AppealLabelModal.snapPoints - element = <AppealLabelModal.Component {...activeModal} /> } else if (activeModal?.name === 'create-or-edit-list') { snapPoints = CreateOrEditListModal.snapPoints element = <CreateOrEditListModal.Component {...activeModal} /> @@ -109,18 +99,12 @@ export function ModalsContainer() { } else if (activeModal?.name === 'add-app-password') { snapPoints = AddAppPassword.snapPoints element = <AddAppPassword.Component /> - } else if (activeModal?.name === 'content-filtering-settings') { - snapPoints = ContentFilteringSettingsModal.snapPoints - element = <ContentFilteringSettingsModal.Component /> } else if (activeModal?.name === 'content-languages-settings') { snapPoints = ContentLanguagesSettingsModal.snapPoints element = <ContentLanguagesSettingsModal.Component /> } else if (activeModal?.name === 'post-languages-settings') { snapPoints = PostLanguagesSettingsModal.snapPoints element = <PostLanguagesSettingsModal.Component /> - } else if (activeModal?.name === 'moderation-details') { - snapPoints = ModerationDetailsModal.snapPoints - element = <ModerationDetailsModal.Component {...activeModal} /> } else if (activeModal?.name === 'verify-email') { snapPoints = VerifyEmailModal.snapPoints element = <VerifyEmailModal.Component {...activeModal} /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 66ea2311f..7e5d548ac 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -8,8 +8,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {useModals, useModalControls} from '#/state/modals' import type {Modal as ModalIface} from '#/state/modals' import * as EditProfileModal from './EditProfile' -import * as ReportModal from './report/Modal' -import * as AppealLabelModal from './AppealLabel' import * as CreateOrEditListModal from './CreateOrEditList' import * as UserAddRemoveLists from './UserAddRemoveLists' import * as ListAddUserModal from './ListAddRemoveUsers' @@ -23,10 +21,8 @@ import * as EditImageModal from './EditImage' import * as ChangeHandleModal from './ChangeHandle' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' -import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' -import * as ModerationDetailsModal from './ModerationDetails' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as ChangePasswordModal from './ChangePassword' @@ -78,10 +74,6 @@ function Modal({modal}: {modal: ModalIface}) { let element if (modal.name === 'edit-profile') { element = <EditProfileModal.Component {...modal} /> - } else if (modal.name === 'report') { - element = <ReportModal.Component {...modal} /> - } else if (modal.name === 'appeal-label') { - element = <AppealLabelModal.Component {...modal} /> } else if (modal.name === 'create-or-edit-list') { element = <CreateOrEditListModal.Component {...modal} /> } else if (modal.name === 'user-add-remove-lists') { @@ -104,8 +96,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <InviteCodesModal.Component /> } else if (modal.name === 'add-app-password') { element = <AddAppPassword.Component /> - } else if (modal.name === 'content-filtering-settings') { - element = <ContentFilteringSettingsModal.Component /> } else if (modal.name === 'content-languages-settings') { element = <ContentLanguagesSettingsModal.Component /> } else if (modal.name === 'post-languages-settings') { @@ -114,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <AltTextImageModal.Component {...modal} /> } else if (modal.name === 'edit-image') { element = <EditImageModal.Component {...modal} /> - } else if (modal.name === 'moderation-details') { - element = <ModerationDetailsModal.Component {...modal} /> } else if (modal.name === 'verify-email') { element = <VerifyEmailModal.Component {...modal} /> } else if (modal.name === 'change-email') { diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx deleted file mode 100644 index 6c0227619..000000000 --- a/src/view/com/modals/ModerationDetails.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {ModerationUI} from '@atproto/api' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {Text} from '../util/text/Text' -import {TextLink} from '../util/Link' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb} from 'platform/detection' -import {listUriToHref} from 'lib/strings/url-helpers' -import {Button} from '../util/forms/Button' -import {useModalControls} from '#/state/modals' -import {useLingui} from '@lingui/react' -import {Trans, msg} from '@lingui/macro' - -export const snapPoints = [300] - -export function Component({ - context, - moderation, -}: { - context: 'account' | 'content' - moderation: ModerationUI -}) { - const {closeModal} = useModalControls() - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') - const {_} = useLingui() - - let name - let description - if (!moderation.cause) { - name = _(msg`Content Warning`) - description = _( - msg`Moderator has chosen to set a general warning on the content.`, - ) - } else if (moderation.cause.type === 'blocking') { - if (moderation.cause.source.type === 'list') { - const list = moderation.cause.source.list - name = _(msg`User Blocked by List`) - description = ( - <Trans> - This user is included in the{' '} - <TextLink - type="2xl" - href={listUriToHref(list.uri)} - text={list.name} - style={pal.link} - />{' '} - list which you have blocked. - </Trans> - ) - } else { - name = _(msg`User Blocked`) - description = _( - msg`You have blocked this user. You cannot view their content.`, - ) - } - } else if (moderation.cause.type === 'blocked-by') { - name = _(msg`User Blocks You`) - description = _( - msg`This user has blocked you. You cannot view their content.`, - ) - } else if (moderation.cause.type === 'block-other') { - name = _(msg`Content Not Available`) - description = _( - msg`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 = _(msg`Account Muted by List`) - description = ( - <Trans> - This user is included in the{' '} - <TextLink - type="2xl" - href={listUriToHref(list.uri)} - text={list.name} - style={pal.link} - />{' '} - list which you have muted. - </Trans> - ) - } else { - name = _(msg`Account Muted`) - description = _(msg`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, - { - paddingHorizontal: isMobile ? 14 : 0, - }, - 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={() => { - closeModal() - }}> - <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}> - <Trans>Okay</Trans> - </Text> - </Button> - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - marginBottom: 12, - }, - description: { - textAlign: 'center', - }, - btn: { - paddingVertical: 14, - marginTop: isWeb ? 40 : 0, - marginBottom: isWeb ? 0 : 40, - }, -}) diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx deleted file mode 100644 index 0ebb25ce5..000000000 --- a/src/view/com/modals/report/InputIssueDetails.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react' -import {View, TouchableOpacity, StyleSheet} from 'react-native' -import {TextInput} from '../util' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {CharProgress} from '../../composer/char-progress/CharProgress' -import {Text} from '../../util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {SendReportButton} from './SendReportButton' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -export function InputIssueDetails({ - details, - setDetails, - goBack, - submitReport, - isProcessing, -}: { - details: string | undefined - setDetails: (v: string) => void - goBack: () => void - submitReport: () => void - isProcessing: boolean -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {isMobile} = useWebMediaQueries() - - return ( - <View - style={{ - marginTop: isMobile ? 12 : 0, - }}> - <TouchableOpacity - testID="addDetailsBtn" - style={[s.mb10, styles.backBtn]} - onPress={goBack} - accessibilityRole="button" - accessibilityLabel={_(msg`Add details`)} - accessibilityHint={_(msg`Add more details to your report`)}> - <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} /> - <Text style={[pal.text, s.f18, pal.link]}> - {' '} - <Trans>Back</Trans> - </Text> - </TouchableOpacity> - <View style={[pal.btn, styles.detailsInputContainer]}> - <TextInput - accessibilityLabel={_(msg`Text input field`)} - accessibilityHint={_(msg`Enter a reason for reporting this post.`)} - placeholder={_(msg`Enter a reason or any other details here.`)} - placeholderTextColor={pal.textLight.color} - value={details} - onChangeText={setDetails} - autoFocus={true} - numberOfLines={3} - multiline={true} - textAlignVertical="top" - maxLength={300} - style={[styles.detailsInput, pal.text]} - /> - <View style={styles.detailsInputBottomBar}> - <View style={styles.charCounter}> - <CharProgress count={details?.length || 0} /> - </View> - </View> - </View> - <SendReportButton onPress={submitReport} isProcessing={isProcessing} /> - </View> - ) -} - -const styles = StyleSheet.create({ - backBtn: { - flexDirection: 'row', - alignItems: 'center', - }, - detailsInputContainer: { - borderRadius: 8, - }, - detailsInput: { - paddingHorizontal: 12, - paddingTop: 12, - paddingBottom: 12, - borderRadius: 8, - minHeight: 100, - fontSize: 16, - }, - detailsInputBottomBar: { - alignSelf: 'flex-end', - }, - charCounter: { - flexDirection: 'row', - alignItems: 'center', - paddingRight: 10, - paddingBottom: 8, - }, -}) diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx deleted file mode 100644 index 02ecefc0f..000000000 --- a/src/view/com/modals/report/Modal.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React, {useState, useMemo} from 'react' -import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' -import {AtUri} from '@atproto/api' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -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 {SendReportButton} from './SendReportButton' -import {InputIssueDetails} from './InputIssueDetails' -import {ReportReasonOptions} from './ReasonOptions' -import {CollectionId} from './types' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {getAgent} from '#/state/session' - -const DMCA_LINK = 'https://bsky.social/about/support/copyright' - -export const snapPoints = [575] - -const CollectionNames = { - [CollectionId.FeedGenerator]: <Trans>Feed</Trans>, - [CollectionId.Profile]: <Trans>Profile</Trans>, - [CollectionId.List]: <Trans>List</Trans>, - [CollectionId.Post]: <Trans context="description">Post</Trans>, -} - -type ReportComponentProps = - | { - uri: string - cid: string - } - | { - did: string - } - -export function Component(content: ReportComponentProps) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - const {_} = useLingui() - const {isMobile} = useWebMediaQueries() - const [isProcessing, setIsProcessing] = 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('') - if (!issue) { - return - } - setIsProcessing(true) - try { - if (issue === '__copyright__') { - Linking.openURL(DMCA_LINK) - closeModal() - return - } - const $type = !isAccountReport - ? 'com.atproto.repo.strongRef' - : 'com.atproto.admin.defs#repoRef' - await getAgent().createModerationReport({ - reasonType: issue, - subject: { - $type, - ...content, - }, - reason: details, - }) - Toast.show( - _(msg`Thank you for your report! We'll look into it promptly.`), - ) - - closeModal() - return - } catch (e: any) { - setError(cleanError(e)) - setIsProcessing(false) - } - } - - const goBack = () => { - setShowDetailsInput(false) - } - - return ( - <ScrollView testID="reportModal" style={[s.flex1, pal.view]}> - <View - style={[ - styles.container, - isMobile && { - paddingBottom: 40, - }, - ]}> - {showDetailsInput ? ( - <InputIssueDetails - details={details} - setDetails={setDetails} - goBack={goBack} - submitReport={submitReport} - isProcessing={isProcessing} - /> - ) : ( - <SelectIssue - setShowDetailsInput={setShowDetailsInput} - error={error} - issue={issue} - setIssue={setIssue} - submitReport={submitReport} - isProcessing={isProcessing} - atUri={atUri} - /> - )} - </View> - </ScrollView> - ) -} - -// If no atUri is passed, that means the reporting collection is account -const getCollectionNameForReport = (atUri: AtUri | null) => { - if (!atUri) return <Trans>Account</Trans> - // Generic fallback for any collection being reported - return ( - CollectionNames[atUri.collection as CollectionId] || <Trans>Content</Trans> - ) -} - -const SelectIssue = ({ - error, - setShowDetailsInput, - issue, - setIssue, - submitReport, - isProcessing, - atUri, -}: { - error: string | undefined - setShowDetailsInput: (v: boolean) => void - issue: string | undefined - setIssue: (v: string) => void - submitReport: () => void - isProcessing: boolean - atUri: AtUri | null -}) => { - const pal = usePalette('default') - const {_} = useLingui() - const collectionName = getCollectionNameForReport(atUri) - const onSelectIssue = (v: string) => setIssue(v) - const goToDetails = () => { - if (issue === '__copyright__') { - Linking.openURL(DMCA_LINK) - return - } - setShowDetailsInput(true) - } - - return ( - <> - <Text style={[pal.text, styles.title]}> - <Trans>Report {collectionName}</Trans> - </Text> - <Text style={[pal.textLight, styles.description]}> - <Trans>What is the issue with this {collectionName}?</Trans> - </Text> - <View style={{marginBottom: 10}}> - <ReportReasonOptions - atUri={atUri} - selectedIssue={issue} - onSelectIssue={onSelectIssue} - /> - </View> - {error ? <ErrorMessage message={error} /> : undefined} - {/* 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} - isProcessing={isProcessing} - /> - <TouchableOpacity - testID="addDetailsBtn" - style={styles.addDetailsBtn} - onPress={goToDetails} - accessibilityRole="button" - accessibilityLabel={_(msg`Add details`)} - accessibilityHint={_(msg`Add more details to your report`)}> - <Text style={[s.f18, pal.link]}> - <Trans>Add details to report</Trans> - </Text> - </TouchableOpacity> - </> - ) : undefined} - </> - ) -} - -const styles = StyleSheet.create({ - container: { - paddingHorizontal: 10, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - fontSize: 24, - marginBottom: 12, - }, - description: { - textAlign: 'center', - fontSize: 17, - paddingHorizontal: 22, - marginBottom: 10, - }, - addDetailsBtn: { - padding: 14, - alignSelf: 'center', - }, -}) diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx deleted file mode 100644 index 1c67bd26c..000000000 --- a/src/view/com/modals/report/ReasonOptions.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import {View} from 'react-native' -import React, {useMemo} from 'react' -import {AtUri, ComAtprotoModerationDefs} from '@atproto/api' -import {Trans} from '@lingui/macro' - -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: JSX.Element; description: JSX.Element}> -const CommonReasons = { - [ComAtprotoModerationDefs.REASONRUDE]: { - title: <Trans>Anti-Social Behavior</Trans>, - description: <Trans>Harassment, trolling, or intolerance</Trans>, - }, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: <Trans>Illegal and Urgent</Trans>, - description: <Trans>Glaring violations of law or terms of service</Trans>, - }, - [ComAtprotoModerationDefs.REASONOTHER]: { - title: <Trans>Other</Trans>, - description: <Trans>An issue not included in these options</Trans>, - }, -} -const CollectionToReasonsMap: Record<string, ReasonMap> = { - [CollectionId.Post]: { - [ComAtprotoModerationDefs.REASONSPAM]: { - title: <Trans>Spam</Trans>, - description: <Trans>Excessive mentions or replies</Trans>, - }, - [ComAtprotoModerationDefs.REASONSEXUAL]: { - title: <Trans>Unwanted Sexual Content</Trans>, - description: <Trans>Nudity or pornography not labeled as such</Trans>, - }, - __copyright__: { - title: <Trans>Copyright Violation</Trans>, - description: <Trans>Contains copyrighted material</Trans>, - }, - ...CommonReasons, - }, - [CollectionId.List]: { - ...CommonReasons, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: <Trans>Name or Description Violates Community Standards</Trans>, - description: <Trans>Terms used violate community standards</Trans>, - }, - }, -} -const AccountReportReasons = { - [ComAtprotoModerationDefs.REASONMISLEADING]: { - title: <Trans>Misleading Account</Trans>, - description: ( - <Trans>Impersonation or false claims about identity or affiliation</Trans> - ), - }, - [ComAtprotoModerationDefs.REASONSPAM]: { - title: <Trans>Frequently Posts Unwanted Content</Trans>, - description: <Trans>Spam; excessive mentions or replies</Trans>, - }, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: <Trans>Name or Description Violates Community Standards</Trans>, - description: <Trans>Terms used violate community standards</Trans>, - }, -} - -const Option = ({ - pal, - title, - description, -}: { - pal: UsePaletteValue - description: JSX.Element - title: JSX.Element -}) => { - 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/SendReportButton.tsx b/src/view/com/modals/report/SendReportButton.tsx deleted file mode 100644 index 40c239bff..000000000 --- a/src/view/com/modals/report/SendReportButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import LinearGradient from 'react-native-linear-gradient' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {Text} from '../../util/text/Text' -import {s, gradients, colors} from 'lib/styles' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -export function SendReportButton({ - onPress, - isProcessing, -}: { - onPress: () => void - isProcessing: boolean -}) { - const {_} = useLingui() - // loading state - // = - if (isProcessing) { - return ( - <View style={[styles.btn, s.mt10]}> - <ActivityIndicator /> - </View> - ) - } - return ( - <TouchableOpacity - testID="sendReportBtn" - style={s.mt10} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={_(msg`Report post`)} - accessibilityHint={`Reports post with reason and details`}> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}> - <Trans>Send Report</Trans> - </Text> - </LinearGradient> - </TouchableOpacity> - ) -} - -const styles = StyleSheet.create({ - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 14, - backgroundColor: colors.gray1, - }, -}) diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts deleted file mode 100644 index ca947ecbd..000000000 --- a/src/view/com/modals/report/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -// 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 a46870265..b16554790 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -11,7 +11,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, ModerationOpts, - ProfileModeration, + ModerationDecision, moderateProfile, AppBskyEmbedRecordWithMedia, } from '@atproto/api' @@ -54,7 +54,7 @@ interface Author { handle: string displayName?: string avatar?: string - moderation: ProfileModeration + moderation: ModerationDecision } let FeedItem = ({ @@ -336,7 +336,7 @@ function CondensedAuthorsList({ did={authors[0].did} handle={authors[0].handle} avatar={authors[0].avatar} - moderation={authors[0].moderation.avatar} + moderation={authors[0].moderation.ui('avatar')} /> </View> ) @@ -354,7 +354,7 @@ function CondensedAuthorsList({ <UserAvatar size={35} avatar={author.avatar} - moderation={author.moderation.avatar} + moderation={author.moderation.ui('avatar')} /> </View> ))} @@ -412,7 +412,7 @@ function ExpandedAuthorsList({ <UserAvatar size={35} avatar={author.avatar} - moderation={author.moderation.avatar} + moderation={author.moderation.ui('avatar')} /> </View> <View style={s.flex1}> diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 55463dc13..0760ed7ff 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -8,7 +8,7 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {logger} from '#/logger' import {LoadingScreen} from '../util/LoadingScreen' import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {usePostLikedByQuery} from '#/state/queries/post-liked-by' +import {useLikedByQuery} from '#/state/queries/post-liked-by' import {cleanError} from '#/lib/strings/errors' export function PostLikedBy({uri}: {uri: string}) { @@ -28,7 +28,7 @@ export function PostLikedBy({uri}: {uri: string}) { isError, error, refetch, - } = usePostLikedByQuery(resolvedUri?.uri) + } = useLikedByQuery(resolvedUri?.uri) const likes = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.likes) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index a7ee42a94..bac7018c3 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -106,11 +106,12 @@ export function PostThread({ ? moderatePost(rootPost, moderationOpts) : undefined - const cause = mod?.content.cause - - return cause - ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated' - : false + return !!mod + ?.ui('contentList') + .blurs.find( + cause => + cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated', + ) }, [rootPost, moderationOpts]) useSetTitle( diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index aa2e1d8e5..d790ab4b5 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -5,7 +5,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, RichText as RichTextAPI, - PostModeration, + ModerationDecision, } from '@atproto/api' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' @@ -19,14 +19,14 @@ import {niceDate} from 'lib/strings/time' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {countLines, pluralize} from 'lib/strings/helpers' -import {isEmbedByEmbedder} from 'lib/embeds' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 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 {PostAlerts} from '../util/moderation/PostAlerts' +import {PostHider} from '../../../components/moderation/PostHider' +import {ContentHider} from '../../../components/moderation/ContentHider' +import {PostAlerts} from '../../../components/moderation/PostAlerts' +import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' import {formatCount} from '../util/numeric/format' @@ -147,7 +147,7 @@ let PostThreadItemLoaded = ({ post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record richText: RichTextAPI - moderation: PostModeration + moderation: ModerationDecision treeView: boolean depth: number prevPost: ThreadPost | undefined @@ -175,7 +175,6 @@ let PostThreadItemLoaded = ({ const itemTitle = _(msg`Post by ${post.author.handle}`) const authorHref = makeProfileLink(post.author) const authorTitle = post.author.handle - const isAuthorMuted = post.author.viewer?.muted const likesHref = React.useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') @@ -256,7 +255,7 @@ let PostThreadItemLoaded = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> @@ -271,35 +270,12 @@ let PostThreadItemLoaded = ({ {sanitizeDisplayName( post.author.displayName || sanitizeHandle(post.author.handle), + moderation.ui('displayName'), )} </Text> </Link> </View> <View style={styles.meta}> - {isAuthorMuted && ( - <View - style={[ - pal.viewLight, - { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - borderRadius: 6, - paddingHorizontal: 6, - paddingVertical: 2, - marginRight: 4, - }, - ]}> - <FontAwesomeIcon - icon={['far', 'eye-slash']} - size={12} - color={pal.colors.textLight} - /> - <Text type="sm-medium" style={pal.textLight}> - <Trans>Muted</Trans> - </Text> - </View> - )} <Link style={s.flex1} href={authorHref} title={authorTitle}> <Text type="md" style={[pal.textLight]} numberOfLines={1}> {sanitizeHandle(post.author.handle, '@')} @@ -312,15 +288,16 @@ let PostThreadItemLoaded = ({ )} </View> <View style={[s.pl10, s.pr10, s.pb10]}> + <LabelsOnMyPost post={post} /> <ContentHider - moderation={moderation.content} + modui={moderation.ui('contentView')} ignoreMute style={styles.contentHider} childContainerStyle={styles.contentHiderChild}> <PostAlerts - moderation={moderation.content} + modui={moderation.ui('contentView')} includeMute - style={styles.alert} + style={[a.pt_2xs, a.pb_sm]} /> {richText?.text ? ( <View @@ -338,18 +315,9 @@ let PostThreadItemLoaded = ({ </View> ) : undefined} {post.embed && ( - <ContentHider - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} - ignoreQuoteDecisions - style={s.mb10}> - <PostEmbeds - embed={post.embed} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - /> - </ContentHider> + <View style={[a.pb_sm]}> + <PostEmbeds embed={post.embed} moderation={moderation} /> + </View> )} </ContentHider> <ExpandedPostDetails @@ -432,7 +400,8 @@ let PostThreadItemLoaded = ({ <PostHider testID={`postThreadItem-by-${post.author.handle}`} href={postHref} - moderation={moderation.content} + style={[pal.view]} + modui={moderation.ui('contentList')} iconSize={isThreadedChild ? 26 : 38} iconStyles={ isThreadedChild @@ -482,7 +451,7 @@ let PostThreadItemLoaded = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> {showChildReplyLine && ( @@ -508,19 +477,21 @@ let PostThreadItemLoaded = ({ }> <PostMeta author={post.author} + moderation={moderation} authorHasWarning={!!post.author.labels?.length} timestamp={post.indexedAt} postHref={postHref} showAvatar={isThreadedChild} - avatarModeration={moderation.avatar} + avatarModeration={moderation.ui('avatar')} avatarSize={28} displayNameType="md-bold" displayNameStyle={isThreadedChild && s.ml2} style={isThreadedChild && s.mb2} /> + <LabelsOnMyPost post={post} /> <PostAlerts - moderation={moderation.content} - style={styles.alert} + modui={moderation.ui('contentList')} + style={[a.pt_xs, a.pb_sm]} /> {richText?.text ? ( <View style={styles.postTextContainer}> @@ -542,18 +513,9 @@ let PostThreadItemLoaded = ({ /> ) : undefined} {post.embed && ( - <ContentHider - style={styles.contentHider} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} - ignoreQuoteDecisions> - <PostEmbeds - embed={post.embed} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - /> - </ContentHider> + <View style={[a.pb_xs]}> + <PostEmbeds embed={post.embed} moderation={moderation} /> + </View> )} <PostCtrls post={post} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 7e53eb271..c7bd4ba2f 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -4,7 +4,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AtUri, - PostModeration, + ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' @@ -14,8 +14,9 @@ import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/post-ctrls/PostCtrls' -import {ContentHider} from '../util/moderation/ContentHider' -import {PostAlerts} from '../util/moderation/PostAlerts' +import {ContentHider} from '../../../components/moderation/ContentHider' +import {PostAlerts} from '../../../components/moderation/PostAlerts' +import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {Text} from '../util/text/Text' import {RichText} from '#/components/RichText' import {PreviewableUserAvatar} from '../util/UserAvatar' @@ -93,7 +94,7 @@ function PostInner({ post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record richText: RichTextAPI - moderation: PostModeration + moderation: ModerationDecision showReplyLine?: boolean style?: StyleProp<ViewStyle> }) { @@ -142,12 +143,13 @@ function PostInner({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> <PostMeta author={post.author} + moderation={moderation} authorHasWarning={!!post.author.labels?.length} timestamp={post.indexedAt} postHref={itemHref} @@ -176,11 +178,15 @@ function PostInner({ </Text> </View> )} + <LabelsOnMyPost post={post} /> <ContentHider - moderation={moderation.content} + modui={moderation.ui('contentView')} style={styles.contentHider} childContainerStyle={styles.contentHiderChild}> - <PostAlerts moderation={moderation.content} style={styles.alert} /> + <PostAlerts + modui={moderation.ui('contentView')} + style={[a.py_xs]} + /> {richText.text ? ( <View style={styles.postTextContainer}> <RichText @@ -202,17 +208,7 @@ function PostInner({ /> ) : undefined} {post.embed ? ( - <ContentHider - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - ignoreQuoteDecisions - style={styles.contentHider}> - <PostEmbeds - embed={post.embed} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - /> - </ContentHider> + <PostEmbeds embed={post.embed} moderation={moderation} /> ) : null} </ContentHider> <PostCtrls diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index f3911da60..0706ddb9b 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -4,7 +4,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AtUri, - PostModeration, + ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import { @@ -18,8 +18,9 @@ import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostEmbeds} from '../util/post-embeds' -import {ContentHider} from '../util/moderation/ContentHider' -import {PostAlerts} from '../util/moderation/PostAlerts' +import {ContentHider} from '#/components/moderation/ContentHider' +import {PostAlerts} from '../../../components/moderation/PostAlerts' +import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {RichText} from '#/components/RichText' import {PreviewableUserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' @@ -27,13 +28,11 @@ import {usePalette} from 'lib/hooks/usePalette' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' -import {isEmbedByEmbedder} from 'lib/embeds' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' import {useComposerControls} from '#/state/shell/composer' import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' import {FeedNameText} from '../util/FeedInfoText' -import {useSession} from '#/state/session' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {atoms as a} from '#/alf' @@ -50,7 +49,7 @@ export function FeedItem({ post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined - moderation: PostModeration + moderation: ModerationDecision isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean @@ -100,7 +99,7 @@ let FeedItemInner = ({ record: AppBskyFeedPost.Record reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined richText: RichTextAPI - moderation: PostModeration + moderation: ModerationDecision isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean @@ -108,14 +107,10 @@ let FeedItemInner = ({ const {openComposer} = useComposerControls() const pal = usePalette('default') const {_} = useLingui() - const {currentAccount} = useSession() const href = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey) }, [post.uri, post.author]) - const isModeratedPost = - moderation.decisions.post.cause?.type === 'label' && - moderation.decisions.post.cause.label.src !== currentAccount?.did const replyAuthorDid = useMemo(() => { if (!record?.reply) { @@ -148,7 +143,7 @@ let FeedItemInner = ({ borderColor: pal.colors.border, paddingBottom: isThreadLastChild || (!isThreadChild && !isThreadParent) - ? 6 + ? 8 : undefined, }, isThreadChild ? styles.outerSmallTop : undefined, @@ -229,6 +224,7 @@ let FeedItemInner = ({ numberOfLines={1} text={sanitizeDisplayName( reason.by.displayName || sanitizeHandle(reason.by.handle), + moderation.ui('displayName'), )} href={makeProfileLink(reason.by)} /> @@ -246,7 +242,7 @@ let FeedItemInner = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> {isThreadParent && ( <View @@ -264,6 +260,7 @@ let FeedItemInner = ({ <View style={styles.layoutContent}> <PostMeta author={post.author} + moderation={moderation} authorHasWarning={!!post.author.labels?.length} timestamp={post.indexedAt} postHref={href} @@ -295,6 +292,7 @@ let FeedItemInner = ({ </Text> </View> )} + <LabelsOnMyPost post={post} /> <PostContent moderation={moderation} richText={richText} @@ -306,9 +304,6 @@ let FeedItemInner = ({ record={record} richText={richText} onPressReply={onPressReply} - showAppealLabelItem={ - post.author.did === currentAccount?.did && isModeratedPost - } logContext="FeedItem" /> </View> @@ -324,7 +319,7 @@ let PostContent = ({ postEmbed, postAuthor, }: { - moderation: PostModeration + moderation: ModerationDecision richText: RichTextAPI postEmbed: AppBskyFeedDefs.PostView['embed'] postAuthor: AppBskyFeedDefs.PostView['author'] @@ -342,10 +337,10 @@ let PostContent = ({ return ( <ContentHider testID="contentHider-post" - moderation={moderation.content} + modui={moderation.ui('contentList')} ignoreMute childContainerStyle={styles.contentHiderChild}> - <PostAlerts moderation={moderation.content} style={styles.alert} /> + <PostAlerts modui={moderation.ui('contentList')} style={[a.py_xs]} /> {richText.text ? ( <View style={styles.postTextContainer}> <RichText @@ -367,19 +362,9 @@ let PostContent = ({ /> ) : undefined} {postEmbed ? ( - <ContentHider - testID="contentHider-embed" - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - ignoreMute={isEmbedByEmbedder(postEmbed, postAuthor.did)} - ignoreQuoteDecisions - style={styles.embed}> - <PostEmbeds - embed={postEmbed} - moderation={moderation.embed} - moderationDecisions={moderation.decisions} - /> - </ContentHider> + <View style={[a.pb_sm]}> + <PostEmbeds embed={postEmbed} moderation={moderation} /> + </View> ) : null} </ContentHider> ) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 019e6c10e..d909bda85 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -3,7 +3,8 @@ import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import { AppBskyActorDefs, moderateProfile, - ProfileModeration, + ModerationCause, + ModerationDecision, } from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' @@ -14,16 +15,13 @@ import {FollowButton} from './FollowButton' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' -import { - describeModerationCause, - getProfileModerationCauses, - getModerationCauseKey, -} from 'lib/moderation' +import {getModerationCauseKey, isJustAMute} from 'lib/moderation' import {Shadow} from '#/state/cache/types' import {useModerationOpts} from '#/state/queries/preferences' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import {Trans} from '@lingui/macro' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' export function ProfileCard({ testID, @@ -33,6 +31,7 @@ export function ProfileCard({ noBorder, followers, renderButton, + onPress, style, }: { testID?: string @@ -44,6 +43,7 @@ export function ProfileCard({ renderButton?: ( profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, ) => React.ReactNode + onPress?: () => void style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -53,11 +53,8 @@ export function ProfileCard({ return null } const moderation = moderateProfile(profile, moderationOpts) - if ( - !noModFilter && - moderation.account.filter && - moderation.account.cause?.type !== 'muted' - ) { + const modui = moderation.ui('profileList') + if (!noModFilter && modui.filter && !isJustAMute(modui)) { return null } @@ -73,6 +70,7 @@ export function ProfileCard({ ]} href={makeProfileLink(profile)} title={profile.handle} + onBeforePress={onPress} asAnchor anchorNoUnderline> <View style={styles.layout}> @@ -80,7 +78,7 @@ export function ProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> @@ -91,7 +89,7 @@ export function ProfileCard({ lineHeight={1.2}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> @@ -119,17 +117,17 @@ export function ProfileCard({ ) } -function ProfileCardPills({ +export function ProfileCardPills({ followedBy, moderation, }: { followedBy: boolean - moderation: ProfileModeration + moderation: ModerationDecision }) { const pal = usePalette('default') - const causes = getProfileModerationCauses(moderation) - if (!followedBy && !causes.length) { + const modui = moderation.ui('profileList') + if (!followedBy && !modui.inform && !modui.alert) { return null } @@ -142,19 +140,41 @@ function ProfileCardPills({ </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> - ) - })} + {modui.alerts.map(alert => ( + <ProfileCardPillModerationCause + key={getModerationCauseKey(alert)} + cause={alert} + severity="alert" + /> + ))} + {modui.informs.map(inform => ( + <ProfileCardPillModerationCause + key={getModerationCauseKey(inform)} + cause={inform} + severity="inform" + /> + ))} + </View> + ) +} + +function ProfileCardPillModerationCause({ + cause, + severity, +}: { + cause: ModerationCause + severity: 'alert' | 'inform' +}) { + const pal = usePalette('default') + const {name} = useModerationCauseDescription(cause) + return ( + <View + style={[s.mt5, pal.btn, styles.pill]} + key={getModerationCauseKey(cause)}> + <Text type="xs" style={pal.text}> + {severity === 'alert' ? '⚠ ' : ''} + {name} + </Text> </View> ) } @@ -177,7 +197,7 @@ function FollowersList({ f, mod: moderateProfile(f, moderationOpts), })) - .filter(({mod}) => !mod.account.filter) + .filter(({mod}) => !mod.ui('profileList').filter) }, [followers, moderationOpts]) if (!followersWithMods?.length) { @@ -199,7 +219,11 @@ function FollowersList({ {followersWithMods.slice(0, 3).map(({f, mod}) => ( <View key={f.did} style={styles.followedByAviContainer}> <View style={[styles.followedByAvi, pal.view]}> - <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} /> + <UserAvatar + avatar={f.avatar} + size={32} + moderation={mod.ui('avatar')} + /> </View> </View> ))} @@ -212,11 +236,13 @@ export function ProfileCardWithFollowBtn({ noBg, noBorder, followers, + onPress, }: { profile: AppBskyActorDefs.ProfileViewBasic noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined + onPress?: () => void }) { const {currentAccount} = useSession() const isMe = profile.did === currentAccount?.did @@ -234,6 +260,7 @@ export function ProfileCardWithFollowBtn({ <FollowButton profile={profileShadow} logContext="ProfileCard" /> ) } + onPress={onPress} /> ) } diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx deleted file mode 100644 index 17dc5ce1b..000000000 --- a/src/view/com/profile/ProfileHeader.tsx +++ /dev/null @@ -1,598 +0,0 @@ -import React, {memo, useMemo} from 'react' -import { - StyleSheet, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useNavigation} from '@react-navigation/native' -import { - AppBskyActorDefs, - ModerationOpts, - moderateProfile, - RichText as RichTextAPI, -} from '@atproto/api' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {NavigationProp} from 'lib/routes/types' -import {isNative} from 'platform/detection' -import {BlurView} from '../util/BlurView' -import * as Toast from '../util/Toast' -import {LoadingPlaceholder} from '../util/LoadingPlaceholder' -import {Text} from '../util/text/Text' -import {ThemedText} from '../util/text/ThemedText' -import {RichText} from '#/components/RichText' -import {UserAvatar} from '../util/UserAvatar' -import {UserBanner} from '../util/UserBanner' -import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' -import {formatCount} from '../util/numeric/format' -import {Link} from '../util/Link' -import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' -import {useModalControls} from '#/state/modals' -import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' -import { - useProfileBlockMutationQueue, - useProfileFollowMutationQueue, -} from '#/state/queries/profile' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {BACK_HITSLOP} from 'lib/constants' -import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' -import {makeProfileLink} from 'lib/routes/links' -import {pluralize} from 'lib/strings/helpers' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {s, colors} from 'lib/styles' -import {logger} from '#/logger' -import {useSession} from '#/state/session' -import {Shadow} from '#/state/cache/types' -import {useRequireAuth} from '#/state/session' -import {LabelInfo} from '../util/moderation/LabelInfo' -import {useProfileShadow} from 'state/cache/profile-shadow' -import {atoms as a} from '#/alf' -import {ProfileMenu} from 'view/com/profile/ProfileMenu' -import * as Prompt from '#/components/Prompt' - -let ProfileHeaderLoading = (_props: {}): React.ReactNode => { - const pal = usePalette('default') - return ( - <View style={pal.view}> - <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} /> - <View - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <LoadingPlaceholder width={80} height={80} style={styles.br40} /> - </View> - <View style={styles.content}> - <View style={[styles.buttonsLine]}> - <LoadingPlaceholder width={167} height={31} style={styles.br50} /> - </View> - </View> - </View> - ) -} -ProfileHeaderLoading = memo(ProfileHeaderLoading) -export {ProfileHeaderLoading} - -interface Props { - profile: AppBskyActorDefs.ProfileViewDetailed - descriptionRT: RichTextAPI | null - moderationOpts: ModerationOpts - hideBackButton?: boolean - isPlaceholderProfile?: boolean -} - -let ProfileHeader = ({ - profile: profileUnshadowed, - descriptionRT, - moderationOpts, - hideBackButton = false, - isPlaceholderProfile, -}: Props): React.ReactNode => { - const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = - useProfileShadow(profileUnshadowed) - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const {currentAccount, hasSession} = useSession() - const requireAuth = useRequireAuth() - const {_} = useLingui() - const {openModal} = useModalControls() - const {openLightbox} = useLightboxControls() - const navigation = useNavigation<NavigationProp>() - const {track} = useAnalytics() - const invalidHandle = isInvalidHandle(profile.handle) - const {isDesktop} = useWebMediaQueries() - const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( - profile, - 'ProfileHeader', - ) - const [__, queueUnblock] = useProfileBlockMutationQueue(profile) - const unblockPromptControl = Prompt.usePromptControl() - const moderation = useMemo( - () => moderateProfile(profile, moderationOpts), - [profile, moderationOpts], - ) - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - const onPressAvi = React.useCallback(() => { - if ( - profile.avatar && - !(moderation.avatar.blur && moderation.avatar.noOverride) - ) { - openLightbox(new ProfileImageLightbox(profile)) - } - }, [openLightbox, profile, moderation]) - - const onPressFollow = () => { - requireAuth(async () => { - try { - track('ProfileHeader:FollowButtonClicked') - await queueFollow() - Toast.show( - _( - msg`Following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, - ), - ) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to follow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }) - } - - const onPressUnfollow = () => { - requireAuth(async () => { - try { - track('ProfileHeader:UnfollowButtonClicked') - await queueUnfollow() - Toast.show( - _( - msg`No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, - )}`, - ), - ) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unfollow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }) - } - - const onPressEditProfile = React.useCallback(() => { - track('ProfileHeader:EditProfileButtonClicked') - openModal({ - name: 'edit-profile', - profile, - }) - }, [track, openModal, profile]) - - const unblockAccount = React.useCallback(async () => { - track('ProfileHeader:UnblockAccountButtonClicked') - try { - await queueUnblock() - Toast.show(_(msg`Account unblocked`)) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unblock account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }, [_, queueUnblock, track]) - - const isMe = React.useMemo( - () => currentAccount?.did === profile.did, - [currentAccount, profile], - ) - - const blockHide = - !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) - const following = formatCount(profile.followsCount || 0) - const followers = formatCount(profile.followersCount || 0) - const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') - - return ( - <View style={[pal.view]} pointerEvents="box-none"> - <View pointerEvents="none"> - {isPlaceholderProfile ? ( - <LoadingPlaceholder - width="100%" - height={150} - style={{borderRadius: 0}} - /> - ) : ( - <UserBanner banner={profile.banner} moderation={moderation.avatar} /> - )} - </View> - <View style={styles.content} pointerEvents="box-none"> - <View style={[styles.buttonsLine]} pointerEvents="box-none"> - {isMe ? ( - <TouchableOpacity - testID="profileHeaderEditProfileButton" - onPress={onPressEditProfile} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel={_(msg`Edit profile`)} - accessibilityHint={_( - msg`Opens editor for profile display name, avatar, background image, and description`, - )}> - <Text type="button" style={pal.text}> - <Trans>Edit Profile</Trans> - </Text> - </TouchableOpacity> - ) : profile.viewer?.blocking ? ( - profile.viewer?.blockingByList ? null : ( - <TouchableOpacity - testID="unblockBtn" - onPress={() => unblockPromptControl.open()} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel={_(msg`Unblock`)} - accessibilityHint=""> - <Text type="button" style={[pal.text, s.bold]}> - <Trans context="action">Unblock</Trans> - </Text> - </TouchableOpacity> - ) - ) : !profile.viewer?.blockedBy ? ( - <> - {hasSession && ( - <TouchableOpacity - testID="suggestedFollowsBtn" - onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} - style={[ - styles.btn, - styles.mainBtn, - pal.btn, - { - paddingHorizontal: 10, - backgroundColor: showSuggestedFollows - ? pal.colors.text - : pal.colors.backgroundLight, - }, - ]} - accessibilityRole="button" - accessibilityLabel={_( - msg`Show follows similar to ${profile.handle}`, - )} - accessibilityHint={_( - msg`Shows a list of users similar to this user.`, - )}> - <FontAwesomeIcon - icon="user-plus" - style={[ - pal.text, - { - color: showSuggestedFollows - ? pal.textInverted.color - : pal.text.color, - }, - ]} - size={14} - /> - </TouchableOpacity> - )} - - {profile.viewer?.following ? ( - <TouchableOpacity - testID="unfollowBtn" - onPress={onPressUnfollow} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel={_(msg`Unfollow ${profile.handle}`)} - accessibilityHint={_( - msg`Hides posts from ${profile.handle} in your feed`, - )}> - <FontAwesomeIcon - icon="check" - style={[pal.text, s.mr5]} - size={14} - /> - <Text type="button" style={pal.text}> - <Trans>Following</Trans> - </Text> - </TouchableOpacity> - ) : ( - <TouchableOpacity - testID="followBtn" - onPress={onPressFollow} - style={[styles.btn, styles.mainBtn, palInverted.view]} - accessibilityRole="button" - accessibilityLabel={_(msg`Follow ${profile.handle}`)} - accessibilityHint={_( - msg`Shows posts from ${profile.handle} in your feed`, - )}> - <FontAwesomeIcon - icon="plus" - style={[palInverted.text, s.mr5]} - /> - <Text type="button" style={[palInverted.text, s.bold]}> - <Trans>Follow</Trans> - </Text> - </TouchableOpacity> - )} - </> - ) : null} - <ProfileMenu profile={profile} /> - </View> - <View pointerEvents="none"> - <Text - testID="profileHeaderDisplayName" - type="title-2xl" - style={[pal.text, styles.title]}> - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, - )} - </Text> - </View> - <View style={styles.handleLine} pointerEvents="none"> - {profile.viewer?.followedBy && !blockHide ? ( - <View style={[styles.pill, pal.btn, s.mr5]}> - <Text type="xs" style={[pal.text]}> - <Trans>Follows you</Trans> - </Text> - </View> - ) : undefined} - <ThemedText - type={invalidHandle ? 'xs' : 'md'} - fg={invalidHandle ? 'error' : 'light'} - border={invalidHandle ? 'error' : undefined} - style={[ - invalidHandle ? styles.invalidHandle : undefined, - styles.handle, - ]}> - {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`} - </ThemedText> - </View> - {!isPlaceholderProfile && !blockHide && ( - <> - <View style={styles.metricsLine} pointerEvents="box-none"> - <Link - testID="profileHeaderFollowersButton" - style={[s.flexRow, s.mr10]} - href={makeProfileLink(profile, 'followers')} - onPressOut={() => - track(`ProfileHeader:FollowersButtonClicked`, { - handle: profile.handle, - }) - } - asAnchor - accessibilityLabel={`${followers} ${pluralizedFollowers}`} - accessibilityHint={_(msg`Opens followers list`)}> - <Text type="md" style={[s.bold, pal.text]}> - {followers}{' '} - </Text> - <Text type="md" style={[pal.textLight]}> - {pluralizedFollowers} - </Text> - </Link> - <Link - testID="profileHeaderFollowsButton" - style={[s.flexRow, s.mr10]} - href={makeProfileLink(profile, 'follows')} - onPressOut={() => - track(`ProfileHeader:FollowsButtonClicked`, { - handle: profile.handle, - }) - } - asAnchor - accessibilityLabel={_(msg`${following} following`)} - accessibilityHint={_(msg`Opens following list`)}> - <Trans> - <Text type="md" style={[s.bold, pal.text]}> - {following}{' '} - </Text> - <Text type="md" style={[pal.textLight]}> - following - </Text> - </Trans> - </Link> - <Text type="md" style={[s.bold, pal.text]}> - {formatCount(profile.postsCount || 0)}{' '} - <Text type="md" style={[pal.textLight]}> - {pluralize(profile.postsCount || 0, 'post')} - </Text> - </Text> - </View> - {descriptionRT && !moderation.profile.blur ? ( - <View pointerEvents="auto" style={[styles.description]}> - <RichText - testID="profileHeaderDescription" - style={[a.text_md]} - numberOfLines={15} - value={descriptionRT} - /> - </View> - ) : undefined} - </> - )} - <ProfileHeaderAlerts moderation={moderation} /> - {isMe && ( - <LabelInfo details={{did: profile.did}} labels={profile.labels} /> - )} - </View> - - {showSuggestedFollows && ( - <ProfileHeaderSuggestedFollows - actorDid={profile.did} - requestDismiss={() => { - if (showSuggestedFollows) { - setShowSuggestedFollows(false) - } else { - track('ProfileHeader:SuggestedFollowsOpened') - setShowSuggestedFollows(true) - } - }} - /> - )} - - {!isDesktop && !hideBackButton && ( - <TouchableWithoutFeedback - testID="profileHeaderBackBtn" - onPress={onPressBack} - hitSlop={BACK_HITSLOP} - accessibilityRole="button" - accessibilityLabel={_(msg`Back`)} - accessibilityHint=""> - <View style={styles.backBtnWrapper}> - <BlurView style={styles.backBtn} blurType="dark"> - <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> - </BlurView> - </View> - </TouchableWithoutFeedback> - )} - <TouchableWithoutFeedback - testID="profileHeaderAviButton" - onPress={onPressAvi} - accessibilityRole="image" - accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)} - accessibilityHint=""> - <View - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <UserAvatar - size={80} - avatar={profile.avatar} - moderation={moderation.avatar} - /> - </View> - </TouchableWithoutFeedback> - <Prompt.Basic - control={unblockPromptControl} - title={_(msg`Unblock Account?`)} - description={_( - msg`The account will be able to interact with you after unblocking.`, - )} - onConfirm={unblockAccount} - confirmButtonCta={ - profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) - } - confirmButtonColor="negative" - /> - </View> - ) -} -ProfileHeader = memo(ProfileHeader) -export {ProfileHeader} - -const styles = StyleSheet.create({ - banner: { - width: '100%', - height: 120, - }, - backBtnWrapper: { - position: 'absolute', - top: 10, - left: 10, - width: 30, - height: 30, - overflow: 'hidden', - borderRadius: 15, - // @ts-ignore web only - cursor: 'pointer', - }, - backBtn: { - width: 30, - height: 30, - borderRadius: 15, - alignItems: 'center', - justifyContent: 'center', - }, - avi: { - position: 'absolute', - top: 110, - left: 10, - width: 84, - height: 84, - borderRadius: 42, - borderWidth: 2, - }, - content: { - paddingTop: 8, - paddingHorizontal: 14, - paddingBottom: 4, - }, - - buttonsLine: { - flexDirection: 'row', - marginLeft: 'auto', - marginBottom: 12, - }, - primaryBtn: { - backgroundColor: colors.blue3, - paddingHorizontal: 24, - paddingVertical: 6, - }, - mainBtn: { - paddingHorizontal: 24, - }, - secondaryBtn: { - paddingHorizontal: 14, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - }, - title: {lineHeight: 38}, - - // Word wrapping appears fine on - // mobile but overflows on desktop - handle: isNative - ? {} - : { - // @ts-ignore web only -prf - wordBreak: 'break-all', - }, - invalidHandle: { - borderWidth: 1, - borderRadius: 4, - paddingHorizontal: 4, - }, - - handleLine: { - flexDirection: 'row', - marginBottom: 8, - }, - - metricsLine: { - flexDirection: 'row', - marginBottom: 8, - }, - - description: { - marginBottom: 8, - }, - - detailLine: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 5, - }, - - pill: { - borderRadius: 4, - paddingHorizontal: 6, - paddingVertical: 2, - }, - - br40: {borderRadius: 40}, - br50: {borderRadius: 50}, -}) diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index 585463f9d..fda95c489 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -219,7 +219,7 @@ function SuggestedFollow({ <UserAvatar size={60} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> <View style={{width: '100%', paddingVertical: 12}}> @@ -229,7 +229,7 @@ function SuggestedFollow({ numberOfLines={1}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 0baa4f394..cb0b1d97c 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -17,6 +17,7 @@ import {toShareUrl} from 'lib/strings/url-helpers' import {makeProfileLink} from 'lib/routes/links' import {useAnalytics} from 'lib/analytics/analytics' import {useModalControls} from 'state/modals' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import { RQKEY as profileQueryKey, useProfileBlockMutationQueue, @@ -31,6 +32,7 @@ import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {logger} from '#/logger' import {Shadow} from 'state/cache/types' import * as Prompt from '#/components/Prompt' @@ -47,12 +49,17 @@ let ProfileMenu = ({ const pal = usePalette('default') const {track} = useAnalytics() const {openModal} = useModalControls() + const reportDialogControl = useReportDialogControl() const queryClient = useQueryClient() const isSelf = currentAccount?.did === profile.did + const isFollowing = profile.viewer?.following + const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy + const isFollowingBlockedAccount = isFollowing && isBlocked + const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) - const [, queueUnfollow] = useProfileFollowMutationQueue( + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, 'ProfileMenu', ) @@ -139,6 +146,19 @@ let ProfileMenu = ({ } }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock]) + const onPressFollowAccount = React.useCallback(async () => { + track('ProfileHeader:FollowButtonClicked') + try { + await queueFollow() + Toast.show(_(msg`Account followed`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }, [_, queueFollow, track]) + const onPressUnfollowAccount = React.useCallback(async () => { track('ProfileHeader:UnfollowButtonClicked') try { @@ -154,11 +174,8 @@ let ProfileMenu = ({ const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') - openModal({ - name: 'report', - did: profile.did, - }) - }, [track, openModal, profile]) + reportDialogControl.open() + }, [track, reportDialogControl]) return ( <EventStopper onKeyDown={false}> @@ -175,10 +192,9 @@ let ProfileMenu = ({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 7, + paddingVertical: 10, borderRadius: 50, - marginLeft: 6, - paddingHorizontal: 14, + paddingHorizontal: 16, }, pal.btn, ]}> @@ -210,10 +226,38 @@ let ProfileMenu = ({ <Menu.ItemIcon icon={Share} /> </Menu.Item> </Menu.Group> + {hasSession && ( <> <Menu.Divider /> <Menu.Group> + {!isSelf && ( + <> + {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && ( + <Menu.Item + testID="profileHeaderDropdownFollowBtn" + label={ + isFollowing + ? _(msg`Unfollow Account`) + : _(msg`Follow Account`) + } + onPress={ + isFollowing + ? onPressUnfollowAccount + : onPressFollowAccount + }> + <Menu.ItemText> + {isFollowing ? ( + <Trans>Unfollow Account</Trans> + ) : ( + <Trans>Follow Account</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} /> + </Menu.Item> + )} + </> + )} <Menu.Item testID="profileHeaderDropdownListAddRemoveBtn" label={_(msg`Add to Lists`)} @@ -225,18 +269,6 @@ let ProfileMenu = ({ </Menu.Item> {!isSelf && ( <> - {profile.viewer?.following && - (profile.viewer.blocking || profile.viewer.blockedBy) && ( - <Menu.Item - testID="profileHeaderDropdownUnfollowBtn" - label={_(msg`Unfollow Account`)} - onPress={onPressUnfollowAccount}> - <Menu.ItemText> - <Trans>Unfollow Account</Trans> - </Menu.ItemText> - <Menu.ItemIcon icon={UserMinus} /> - </Menu.Item> - )} {!profile.viewer?.blocking && !profile.viewer?.mutedByList && ( <Menu.Item @@ -299,6 +331,11 @@ let ProfileMenu = ({ </Menu.Outer> </Menu.Root> + <ReportDialog + control={reportDialogControl} + params={{type: 'account', did: profile.did}} + /> + <Prompt.Basic control={blockPromptControl} title={ @@ -311,6 +348,10 @@ let ProfileMenu = ({ ? _( msg`The account will be able to interact with you after unblocking.`, ) + : profile.associated?.labeler + ? _( + msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`, + ) : _( msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, ) diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx index ed5a2f165..ab6570252 100644 --- a/src/view/com/util/BottomSheetCustomBackdrop.tsx +++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx @@ -6,12 +6,15 @@ import Animated, { interpolate, useAnimatedStyle, } from 'react-native-reanimated' -import {t} from '@lingui/macro' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function createCustomBackdrop( onClose?: (() => void) | undefined, ): React.FC<BottomSheetBackdropProps> { const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => { + const {_} = useLingui() + // animated variables const opacity = useAnimatedStyle(() => ({ opacity: interpolate( @@ -30,7 +33,7 @@ export function createCustomBackdrop( return ( <TouchableWithoutFeedback onPress={onClose} - accessibilityLabel={t`Close bottom drawer`} + accessibilityLabel={_(msg`Close bottom drawer`)} accessibilityHint="" onAccessibilityEscape={() => { if (onClose !== undefined) { diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 5ec1d0014..22fdd606e 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -1,8 +1,9 @@ import React, {Component, ErrorInfo, ReactNode} from 'react' import {ErrorScreen} from './error/ErrorScreen' import {CenteredView} from './Views' -import {t} from '@lingui/macro' +import {msg} from '@lingui/macro' import {logger} from '#/logger' +import {useLingui} from '@lingui/react' interface Props { children?: ReactNode @@ -31,11 +32,7 @@ export class ErrorBoundary extends Component<Props, State> { if (this.state.hasError) { return ( <CenteredView style={{height: '100%', flex: 1}}> - <ErrorScreen - title={t`Oh no!`} - message={t`There was an unexpected issue in the application. Please let us know if this happened to you!`} - details={this.state.error.toString()} - /> + <TranslatedErrorScreen details={this.state.error.toString()} /> </CenteredView> ) } @@ -43,3 +40,17 @@ export class ErrorBoundary extends Component<Props, State> { return this.props.children } } + +function TranslatedErrorScreen({details}: {details?: string}) { + const {_} = useLingui() + + return ( + <ErrorScreen + title={_(msg`Oh no!`)} + message={_( + msg`There was an unexpected issue in the application. Please let us know if this happened to you!`, + )} + details={details} + /> + ) +} diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 7468111b5..b6c512b09 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -47,6 +47,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> { anchorNoUnderline?: boolean navigationAction?: 'push' | 'replace' | 'navigate' onPointerEnter?: () => void + onBeforePress?: () => void } export const Link = memo(function Link({ @@ -60,6 +61,7 @@ export const Link = memo(function Link({ accessible, anchorNoUnderline, navigationAction, + onBeforePress, ...props }: Props) { const t = useTheme() @@ -70,6 +72,7 @@ export const Link = memo(function Link({ const onPress = React.useCallback( (e?: Event) => { + onBeforePress?.() if (typeof href === 'string') { return onPressInner( closeModal, @@ -81,7 +84,7 @@ export const Link = memo(function Link({ ) } }, - [closeModal, navigation, navigationAction, href, openLink], + [closeModal, navigation, navigationAction, href, openLink, onBeforePress], ) if (noFeedback) { @@ -262,6 +265,7 @@ interface TextLinkOnWebOnlyProps extends TextProps { accessibilityHint?: string title?: string navigationAction?: 'push' | 'replace' | 'navigate' + disableMismatchWarning?: boolean onPointerEnter?: () => void } export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ @@ -273,6 +277,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ numberOfLines, lineHeight, navigationAction, + disableMismatchWarning, ...props }: TextLinkOnWebOnlyProps) { if (isWeb) { @@ -287,6 +292,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ lineHeight={lineHeight} title={props.title} navigationAction={navigationAction} + disableMismatchWarning={disableMismatchWarning} {...props} /> ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 3795dcf13..53dc20e71 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -11,7 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {isAndroid, isWeb} from 'platform/detection' import {TimeElapsed} from './TimeElapsed' import {makeProfileLink} from 'lib/routes/links' -import {ModerationUI} from '@atproto/api' +import {ModerationDecision, ModerationUI} from '@atproto/api' import {usePrefetchProfileQuery} from '#/state/queries/profile' interface PostMetaOpts { @@ -21,6 +21,7 @@ interface PostMetaOpts { handle: string displayName?: string | undefined } + moderation: ModerationDecision | undefined authorHasWarning: boolean postHref: string timestamp: string @@ -55,9 +56,14 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { style={[pal.text, opts.displayNameStyle]} numberOfLines={1} lineHeight={1.2} + disableMismatchWarning text={ <> - {sanitizeDisplayName(displayName)} + {sanitizeDisplayName( + displayName, + opts.moderation?.ui('displayName'), + )} + <Text type="md" numberOfLines={1} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 413237397..39bc72303 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -24,9 +24,9 @@ import { } from '#/components/icons/Camera' import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {useTheme} from '#/alf' +import {useTheme, tokens} from '#/alf' -export type UserAvatarType = 'user' | 'algo' | 'list' +export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' interface BaseUserAvatarProps { type?: UserAvatarType @@ -101,6 +101,29 @@ let DefaultAvatar = ({ </Svg> ) } + if (type === 'labeler') { + return ( + <Svg + testID="userAvatarFallback" + width={size} + height={size} + viewBox="0 0 32 32" + fill="none" + stroke="none"> + <Path + d="M28 0H4C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H28C30.2091 32 32 30.2091 32 28V4C32 1.79086 30.2091 0 28 0Z" + fill={tokens.color.temp_purple} + /> + <Path + d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z" + stroke="white" + strokeWidth="2" + strokeLinecap="square" + strokeLinejoin="round" + /> + </Svg> + ) + } return ( <Svg testID="userAvatarFallback" @@ -134,7 +157,7 @@ let UserAvatar = ({ const backgroundColor = pal.colors.backgroundLight const aviStyle = useMemo(() => { - if (type === 'algo' || type === 'list') { + if (type === 'algo' || type === 'list' || type === 'labeler') { return { width: size, height: size, diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index a5ddfee8a..4fb3726cd 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -7,7 +7,7 @@ import {msg, Trans} from '@lingui/macro' import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' -import {useTheme as useAlfTheme} from '#/alf' +import {useTheme as useAlfTheme, tokens} from '#/alf' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import { usePhotoLibraryPermission, @@ -26,10 +26,12 @@ import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/ico import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' export function UserBanner({ + type, banner, moderation, onSelectNewBanner, }: { + type?: 'labeler' | 'default' banner?: string | null moderation?: ModerationUI onSelectNewBanner?: (img: RNImage | null) => void @@ -167,7 +169,10 @@ export function UserBanner({ ) : ( <View testID="userBannerFallback" - style={[styles.bannerImage, styles.defaultBanner]} + style={[ + styles.bannerImage, + type === 'labeler' ? styles.labelerBanner : styles.defaultBanner, + ]} /> ) } @@ -191,4 +196,7 @@ const styles = StyleSheet.create({ defaultBanner: { backgroundColor: '#0070ff', }, + labelerBanner: { + backgroundColor: tokens.color.temp_purple, + }, }) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 8fc3d9ea6..70fbb907f 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -16,7 +16,6 @@ import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' import {useDialogControl} from '#/components/Dialog' import * as Prompt from '#/components/Prompt' -import {useModalControls} from '#/state/modals' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams} from '#/lib/routes/types' import {getCurrentRoute} from 'lib/routes/helpers' @@ -33,6 +32,7 @@ import {useSession} from '#/state/session' import {isWeb} from '#/platform/detection' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {atoms as a, useTheme as useAlf} from '#/alf' import * as Menu from '#/components/Menu' @@ -45,7 +45,6 @@ import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/ import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' let PostDropdownBtn = ({ testID, @@ -55,7 +54,6 @@ let PostDropdownBtn = ({ record, richText, style, - showAppealLabelItem, hitSlop, }: { testID: string @@ -65,7 +63,6 @@ let PostDropdownBtn = ({ record: AppBskyFeedPost.Record richText: RichTextAPI style?: StyleProp<ViewStyle> - showAppealLabelItem?: boolean hitSlop?: PressableProps['hitSlop'] }): React.ReactNode => { const {hasSession, currentAccount} = useSession() @@ -73,7 +70,6 @@ let PostDropdownBtn = ({ const alf = useAlf() const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl - const {openModal} = useModalControls() const langPrefs = useLanguagePrefs() const mutedThreads = useMutedThreads() const toggleThreadMute = useToggleThreadMute() @@ -83,6 +79,7 @@ let PostDropdownBtn = ({ const openLink = useOpenLink() const navigation = useNavigation() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const reportDialogControl = useReportDialogControl() const deletePromptControl = useDialogControl() const hidePromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl() @@ -293,13 +290,7 @@ let PostDropdownBtn = ({ <Menu.Item testID="postDropdownReportBtn" label={_(msg`Report post`)} - onPress={() => { - openModal({ - name: 'report', - uri: postUri, - cid: postCid, - }) - }}> + onPress={() => reportDialogControl.open()}> <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> <Menu.ItemIcon icon={Warning} position="right" /> </Menu.Item> @@ -314,28 +305,6 @@ let PostDropdownBtn = ({ <Menu.ItemIcon icon={Trash} position="right" /> </Menu.Item> )} - - {showAppealLabelItem && ( - <> - <Menu.Divider /> - - <Menu.Item - testID="postDropdownAppealBtn" - label={_(msg`Appeal content warning`)} - onPress={() => { - openModal({ - name: 'appeal-label', - uri: postUri, - cid: postCid, - }) - }}> - <Menu.ItemText> - {_(msg`Appeal content warning`)} - </Menu.ItemText> - <Menu.ItemIcon icon={CircleInfo} position="right" /> - </Menu.Item> - </> - )} </Menu.Group> </Menu.Outer> </Menu.Root> @@ -359,6 +328,15 @@ let PostDropdownBtn = ({ confirmButtonCta={_(msg`Hide`)} /> + <ReportDialog + control={reportDialogControl} + params={{ + type: 'post', + uri: postUri, + cid: postCid, + }} + /> + <Prompt.Basic control={loggedOutWarningPromptControl} title={_(msg`Note about sharing`)} diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx deleted file mode 100644 index cd2545290..000000000 --- a/src/view/com/util/moderation/ContentHider.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {ModerationUI, PostModeration} from '@atproto/api' -import {Text} from '../text/Text' -import {ShieldExclamation} from 'lib/icons' -import {describeModerationCause} from 'lib/moderation' -import {useLingui} from '@lingui/react' -import {msg, Trans} from '@lingui/macro' -import {useModalControls} from '#/state/modals' -import {isPostMediaBlurred} from 'lib/moderation' - -export function ContentHider({ - testID, - moderation, - moderationDecisions, - ignoreMute, - ignoreQuoteDecisions, - style, - childContainerStyle, - children, -}: React.PropsWithChildren<{ - testID?: string - moderation: ModerationUI - moderationDecisions?: PostModeration['decisions'] - ignoreMute?: boolean - ignoreQuoteDecisions?: boolean - style?: StyleProp<ViewStyle> - childContainerStyle?: StyleProp<ViewStyle> -}>) { - const pal = usePalette('default') - const {_} = useLingui() - const [override, setOverride] = React.useState(false) - const {openModal} = useModalControls() - - if ( - !moderation.blur || - (ignoreMute && moderation.cause?.type === 'muted') || - shouldIgnoreQuote(moderationDecisions, ignoreQuoteDecisions) - ) { - return ( - <View testID={testID} style={[styles.outer, style]}> - {children} - </View> - ) - } - - const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '') - const desc = describeModerationCause(moderation.cause, 'content') - return ( - <View testID={testID} style={[styles.outer, style]}> - <Pressable - onPress={() => { - if (!moderation.noOverride) { - setOverride(v => !v) - } else { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) - } - }} - accessibilityRole="button" - accessibilityHint={ - override ? _(msg`Hide the content`) : _(msg`Show the content`) - } - accessibilityLabel="" - style={[ - styles.cover, - moderation.noOverride - ? {borderWidth: 1, borderColor: pal.colors.borderDark} - : pal.viewLight, - ]}> - <Pressable - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint=""> - {isMute ? ( - <FontAwesomeIcon - icon={['far', 'eye-slash']} - size={18} - color={pal.colors.textLight} - /> - ) : ( - <ShieldExclamation size={18} style={pal.textLight} /> - )} - </Pressable> - <Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}> - {desc.name} - </Text> - <View style={styles.showBtn}> - <Text type="lg" style={pal.link}> - {moderation.noOverride ? ( - <Trans>Learn more</Trans> - ) : override ? ( - <Trans>Hide</Trans> - ) : ( - <Trans>Show</Trans> - )} - </Text> - </View> - </Pressable> - {override && <View style={childContainerStyle}>{children}</View>} - </View> - ) -} - -function shouldIgnoreQuote( - decisions: PostModeration['decisions'] | undefined, - ignore: boolean | undefined, -): boolean { - if (!decisions || !ignore) { - return false - } - return !isPostMediaBlurred(decisions) -} - -const styles = StyleSheet.create({ - outer: { - overflow: 'hidden', - }, - cover: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - borderRadius: 8, - marginTop: 4, - paddingVertical: 14, - paddingLeft: 14, - paddingRight: 18, - }, - showBtn: { - marginLeft: 'auto', - alignSelf: 'center', - }, -}) diff --git a/src/view/com/util/moderation/LabelInfo.tsx b/src/view/com/util/moderation/LabelInfo.tsx deleted file mode 100644 index 970338752..000000000 --- a/src/view/com/util/moderation/LabelInfo.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import {ComAtprotoLabelDefs} from '@atproto/api' -import {Text} from '../text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' - -export function LabelInfo({ - details, - labels, - style, -}: { - details: {did: string} | {uri: string; cid: string} - labels: ComAtprotoLabelDefs.Label[] | undefined - style?: StyleProp<ViewStyle> -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {openModal} = useModalControls() - - if (!labels) { - return null - } - labels = labels.filter(l => !l.val.startsWith('!')) - if (!labels.length) { - return null - } - - return ( - <View - style={[ - pal.viewLight, - { - flexDirection: 'row', - flexWrap: 'wrap', - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 8, - }, - style, - ]}> - <Text type="sm" style={pal.text}> - <Trans> - A content warning has been applied to this{' '} - {'did' in details ? 'account' : 'post'}. - </Trans>{' '} - </Text> - <Pressable - accessibilityRole="button" - accessibilityLabel={_(msg`Appeal this decision`)} - accessibilityHint="" - onPress={() => openModal({name: 'appeal-label', ...details})}> - <Text type="sm" style={pal.link}> - <Trans>Appeal this decision.</Trans> - </Text> - </Pressable> - </View> - ) -} diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx deleted file mode 100644 index bc5bf9b32..000000000 --- a/src/view/com/util/moderation/PostAlerts.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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 {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' - -export function PostAlerts({ - moderation, - style, -}: { - moderation: ModerationUI - includeMute?: boolean - style?: StyleProp<ViewStyle> -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {openModal} = useModalControls() - - const shouldAlert = !!moderation.cause && moderation.alert - if (!shouldAlert) { - return null - } - - const desc = describeModerationCause(moderation.cause, 'content') - return ( - <Pressable - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`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 type="lg" style={[pal.link, styles.learnMoreBtn]}> - <Trans>Learn More</Trans> - </Text> - </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 deleted file mode 100644 index ede62e988..000000000 --- a/src/view/com/util/moderation/PostHider.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React, {ComponentProps} from 'react' -import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native' -import {ModerationUI} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {Link} from '../Link' -import {Text} from '../text/Text' -import {addStyle} from 'lib/styles' -import {describeModerationCause} from 'lib/moderation' -import {ShieldExclamation} from 'lib/icons' -import {useLingui} from '@lingui/react' -import {Trans, msg} from '@lingui/macro' -import {useModalControls} from '#/state/modals' - -interface Props extends ComponentProps<typeof Link> { - iconSize: number - iconStyles: StyleProp<ViewStyle> - moderation: ModerationUI -} - -export function PostHider({ - testID, - href, - moderation, - style, - children, - iconSize, - iconStyles, - ...props -}: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const [override, setOverride] = React.useState(false) - const {openModal} = useModalControls() - - if (!moderation.blur) { - return ( - <Link - testID={testID} - style={style} - href={href} - noFeedback - accessible={false} - {...props}> - {children} - </Link> - ) - } - - const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '') - const desc = describeModerationCause(moderation.cause, 'content') - return !override ? ( - <Pressable - onPress={() => { - if (!moderation.noOverride) { - setOverride(v => !v) - } - }} - accessibilityRole="button" - accessibilityHint={ - override ? _(msg`Hide the content`) : _(msg`Show the content`) - } - accessibilityLabel="" - style={[ - styles.description, - override ? {paddingBottom: 0} : undefined, - pal.view, - ]}> - <Pressable - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint=""> - <View - style={[ - pal.viewLight, - { - width: iconSize, - height: iconSize, - borderRadius: iconSize, - alignItems: 'center', - justifyContent: 'center', - }, - iconStyles, - ]}> - {isMute ? ( - <FontAwesomeIcon - icon={['far', 'eye-slash']} - size={14} - color={pal.colors.textLight} - /> - ) : ( - <ShieldExclamation size={14} style={pal.textLight} /> - )} - </View> - </Pressable> - <Text type="sm" style={[{flex: 1}, pal.textLight]} numberOfLines={1}> - {desc.name} - </Text> - {!moderation.noOverride && ( - <Text type="sm" style={[styles.showBtn, pal.link]}> - {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} - </Text> - )} - </Pressable> - ) : ( - <Link - testID={testID} - style={addStyle(style, styles.child)} - href={href} - noFeedback> - {children} - </Link> - ) -} - -const styles = StyleSheet.create({ - description: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingVertical: 10, - paddingLeft: 6, - paddingRight: 18, - marginTop: 1, - }, - showBtn: { - marginLeft: 'auto', - alignSelf: 'center', - }, - child: { - borderWidth: 0, - borderTopWidth: 0, - borderRadius: 8, - }, -}) diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx deleted file mode 100644 index 0f07b679b..000000000 --- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useModalControls} from '#/state/modals' - -export function ProfileHeaderAlerts({ - moderation, - style, -}: { - moderation: ProfileModeration - style?: StyleProp<ViewStyle> -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {openModal} = useModalControls() - - const causes = getProfileModerationCauses(moderation) - if (!causes.length) { - return null - } - - return ( - <View style={styles.grid}> - {causes.map(cause => { - const isMute = cause.type === 'muted' - const desc = describeModerationCause(cause, 'account') - return ( - <Pressable - testID="profileHeaderAlert" - key={desc.name} - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation: {cause}, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint="" - style={[styles.container, pal.viewLight, style]}> - {isMute ? ( - <FontAwesomeIcon - icon={['far', 'eye-slash']} - size={14} - color={pal.colors.textLight} - /> - ) : ( - <ShieldExclamation style={pal.text} size={18} /> - )} - <Text type="sm" style={[{flex: 1}, pal.text]}> - {desc.name} - </Text> - <Text type="sm" style={[pal.link, styles.learnMoreBtn]}> - <Trans>Learn More</Trans> - </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/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx deleted file mode 100644 index 86f0cbf7b..000000000 --- a/src/view/com/util/moderation/ScreenHider.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react' -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 {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {Text} from '../text/Text' -import {Button} from '../forms/Button' -import {describeModerationCause} from 'lib/moderation' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {s} from '#/lib/styles' -import {CenteredView} from '../Views' - -export function ScreenHider({ - testID, - screenDescription, - moderation, - style, - containerStyle, - children, -}: React.PropsWithChildren<{ - testID?: string - screenDescription: string - moderation: ModerationUI - style?: StyleProp<ViewStyle> - containerStyle?: StyleProp<ViewStyle> -}>) { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const {_} = useLingui() - const [override, setOverride] = React.useState(false) - const navigation = useNavigation<NavigationProp>() - const {isMobile} = useWebMediaQueries() - const {openModal} = useModalControls() - - if (!moderation.blur || override) { - return ( - <View testID={testID} style={style}> - {children} - </View> - ) - } - - const isNoPwi = - moderation.cause?.type === 'label' && - moderation.cause?.labelDef.id === '!no-unauthenticated' - const desc = describeModerationCause(moderation.cause, 'account') - return ( - <CenteredView - style={[styles.container, pal.view, containerStyle]} - sideBorders> - <View style={styles.iconContainer}> - <View style={[styles.icon, palInverted.view]}> - <FontAwesomeIcon - icon={isNoPwi ? ['far', 'eye-slash'] : 'exclamation'} - style={pal.textInverted as FontAwesomeIconStyle} - size={24} - /> - </View> - </View> - <Text type="title-2xl" style={[styles.title, pal.text]}> - {isNoPwi ? ( - <Trans>Sign-in Required</Trans> - ) : ( - <Trans>Content Warning</Trans> - )} - </Text> - <Text type="2xl" style={[styles.description, pal.textLight]}> - {isNoPwi ? ( - <Trans> - This account has requested that users sign in to view their profile. - </Trans> - ) : ( - <> - <Trans>This {screenDescription} has been flagged:</Trans> - <Text type="2xl-medium" style={[pal.text, s.ml5]}> - {desc.name}. - </Text> - <TouchableWithoutFeedback - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'account', - moderation, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint=""> - <Text type="2xl" style={pal.link}> - <Trans>Learn More</Trans> - </Text> - </TouchableWithoutFeedback> - </> - )}{' '} - </Text> - {isMobile && <View style={styles.spacer} />} - <View style={styles.btnContainer}> - <Button - type="inverted" - onPress={() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }} - style={styles.btn}> - <Text type="button-lg" style={pal.textInverted}> - <Trans>Go back</Trans> - </Text> - </Button> - {!moderation.noOverride && ( - <Button - type="default" - onPress={() => setOverride(v => !v)} - style={styles.btn}> - <Text type="button-lg" style={pal.text}> - <Trans>Show anyway</Trans> - </Text> - </Button> - )} - </View> - </CenteredView> - ) -} - -const styles = StyleSheet.create({ - spacer: { - flex: 1, - }, - container: { - flex: 1, - paddingTop: 100, - paddingBottom: 150, - }, - iconContainer: { - alignItems: 'center', - marginBottom: 10, - }, - icon: { - borderRadius: 25, - width: 50, - height: 50, - alignItems: 'center', - justifyContent: 'center', - }, - title: { - textAlign: 'center', - marginBottom: 10, - }, - description: { - marginBottom: 10, - paddingHorizontal: 20, - textAlign: 'center', - }, - btnContainer: { - flexDirection: 'row', - justifyContent: 'center', - marginVertical: 10, - gap: 10, - }, - btn: { - paddingHorizontal: 20, - paddingVertical: 14, - }, -}) diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index c96954a11..3fa347a6d 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -41,7 +41,6 @@ let PostCtrls = ({ post, record, richText, - showAppealLabelItem, style, onPressReply, logContext, @@ -50,7 +49,6 @@ let PostCtrls = ({ post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record richText: RichTextAPI - showAppealLabelItem?: boolean style?: StyleProp<ViewStyle> onPressReply: () => void logContext: 'FeedItem' | 'PostThreadItem' | 'Post' @@ -232,7 +230,6 @@ let PostCtrls = ({ postUri={post.uri} record={record} richText={richText} - showAppealLabelItem={showAppealLabelItem} style={styles.btnPad} hitSlop={big ? HITSLOP_20 : HITSLOP_10} /> diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 35b091269..2b1c3e617 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -1,13 +1,15 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import { + AppBskyFeedDefs, AppBskyEmbedRecord, AppBskyFeedPost, AppBskyEmbedImages, AppBskyEmbedRecordWithMedia, - ModerationUI, AppBskyEmbedExternal, RichText as RichTextAPI, + moderatePost, + ModerationDecision, } from '@atproto/api' import {AtUri} from '@atproto/api' import {PostMeta} from '../PostMeta' @@ -16,20 +18,20 @@ import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ComposerOptsQuote} from 'state/shell/composer' import {PostEmbeds} from '.' -import {PostAlerts} from '../moderation/PostAlerts' +import {PostAlerts} from '../../../../components/moderation/PostAlerts' import {makeProfileLink} from 'lib/routes/links' import {InfoCircleIcon} from 'lib/icons' import {Trans} from '@lingui/macro' +import {useModerationOpts} from '#/state/queries/preferences' +import {ContentHider} from '../../../../components/moderation/ContentHider' import {RichText} from '#/components/RichText' import {atoms as a} from '#/alf' export function MaybeQuoteEmbed({ embed, - moderation, style, }: { embed: AppBskyEmbedRecord.View - moderation: ModerationUI style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -39,17 +41,9 @@ export function MaybeQuoteEmbed({ 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, - facets: embed.record.value.facets, - embeds: embed.record.embeds, - }} - moderation={moderation} + <QuoteEmbedModerated + viewRecord={embed.record} + postRecord={embed.record.value} style={style} /> ) @@ -75,19 +69,49 @@ export function MaybeQuoteEmbed({ return null } +function QuoteEmbedModerated({ + viewRecord, + postRecord, + style, +}: { + viewRecord: AppBskyEmbedRecord.ViewRecord + postRecord: AppBskyFeedPost.Record + style?: StyleProp<ViewStyle> +}) { + const moderationOpts = useModerationOpts() + const moderation = React.useMemo(() => { + return moderationOpts + ? moderatePost(viewRecordToPostView(viewRecord), moderationOpts) + : undefined + }, [viewRecord, moderationOpts]) + + const quote = { + author: viewRecord.author, + cid: viewRecord.cid, + uri: viewRecord.uri, + indexedAt: viewRecord.indexedAt, + text: postRecord.text, + facets: postRecord.facets, + embeds: viewRecord.embeds, + } + + return <QuoteEmbed quote={quote} moderation={moderation} style={style} /> +} + export function QuoteEmbed({ quote, moderation, style, }: { quote: ComposerOptsQuote - moderation?: ModerationUI + moderation?: ModerationDecision style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') const itemUrip = new AtUri(quote.uri) const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) const itemTitle = `Post by ${quote.author.handle}` + const richText = React.useMemo( () => quote.text.trim() @@ -95,6 +119,7 @@ export function QuoteEmbed({ : undefined, [quote.text, quote.facets], ) + const embed = React.useMemo(() => { const e = quote.embeds?.[0] @@ -108,40 +133,52 @@ export function QuoteEmbed({ return e.media } }, [quote.embeds]) + return ( - <Link - style={[styles.container, pal.borderDark, style]} - hoverStyle={{borderColor: pal.colors.borderLinkHover}} - href={itemHref} - title={itemTitle}> - <View pointerEvents="none"> - <PostMeta - author={quote.author} - showAvatar - authorHasWarning={false} - postHref={itemHref} - timestamp={quote.indexedAt} - /> - </View> - {moderation ? ( - <PostAlerts moderation={moderation} style={styles.alert} /> - ) : null} - {richText ? ( - <RichText - enableTags - value={richText} - style={[a.text_md]} - numberOfLines={20} - disableLinks - authorHandle={quote.author.handle} - /> - ) : null} - {embed && <PostEmbeds embed={embed} moderation={{}} />} - </Link> + <ContentHider modui={moderation?.ui('contentList')}> + <Link + style={[styles.container, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} + href={itemHref} + title={itemTitle}> + <View pointerEvents="none"> + <PostMeta + author={quote.author} + moderation={moderation} + showAvatar + authorHasWarning={false} + postHref={itemHref} + timestamp={quote.indexedAt} + /> + </View> + {moderation ? ( + <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} /> + ) : null} + {richText ? ( + <RichText + value={richText} + style={[a.text_md]} + numberOfLines={20} + disableLinks + /> + ) : null} + {embed && <PostEmbeds embed={embed} moderation={moderation} />} + </Link> + </ContentHider> ) } -export default QuoteEmbed +function viewRecordToPostView( + viewRecord: AppBskyEmbedRecord.ViewRecord, +): AppBskyFeedDefs.PostView { + const {value, embeds, ...rest} = viewRecord + return { + ...rest, + $type: 'app.bsky.feed.defs#postView', + record: value, + embed: embeds?.[0], + } +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 7e235babb..47091fbb0 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -15,8 +15,7 @@ import { AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyGraphDefs, - ModerationUI, - PostModeration, + ModerationDecision, } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' @@ -26,9 +25,8 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' import {ListEmbed} from './ListEmbed' -import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {ContentHider} from '../moderation/ContentHider' +import {ContentHider} from '../../../../components/moderation/ContentHider' import {isNative} from '#/platform/detection' import {shareUrl} from '#/lib/sharing' @@ -42,12 +40,10 @@ type Embed = export function PostEmbeds({ embed, moderation, - moderationDecisions, style, }: { embed?: Embed - moderation: ModerationUI - moderationDecisions?: PostModeration['decisions'] + moderation?: ModerationDecision style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -66,18 +62,10 @@ export function PostEmbeds({ // quote post with media // = if (AppBskyEmbedRecordWithMedia.isView(embed)) { - const isModOnQuote = - (AppBskyEmbedRecord.isViewRecord(embed.record.record) && - isCauseALabelOnUri(moderation.cause, embed.record.record.uri)) || - (moderationDecisions && isQuoteBlurred(moderationDecisions)) - const mediaModeration = isModOnQuote ? {} : moderation - const quoteModeration = isModOnQuote ? moderation : {} return ( <View style={style}> - <PostEmbeds embed={embed.media} moderation={mediaModeration} /> - <ContentHider moderation={quoteModeration}> - <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> - </ContentHider> + <PostEmbeds embed={embed.media} moderation={moderation} /> + <MaybeQuoteEmbed embed={embed.record} /> </View> ) } @@ -86,6 +74,7 @@ export function PostEmbeds({ // custom feed embed (i.e. generator view) // = if (AppBskyFeedDefs.isGeneratorView(embed.record)) { + // TODO moderation return ( <FeedSourceCard feedUri={embed.record.uri} @@ -97,16 +86,13 @@ export function PostEmbeds({ // list embed if (AppBskyGraphDefs.isListView(embed.record)) { + // TODO moderation return <ListEmbed item={embed.record} /> } // quote post // = - return ( - <ContentHider moderation={moderation}> - <MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} /> - </ContentHider> - ) + return <MaybeQuoteEmbed embed={embed} style={style} /> } // image embed @@ -132,35 +118,41 @@ export function PostEmbeds({ if (images.length === 1) { const {alt, thumb, aspectRatio} = images[0] return ( - <View style={[styles.imagesContainer, style]}> - <AutoSizedImage - alt={alt} - uri={thumb} - dimensionsHint={aspectRatio} - onPress={() => _openLightbox(0)} - onPressIn={() => onPressIn(0)} - style={[styles.singleImage]}> - {alt === '' ? null : ( - <View style={styles.altContainer}> - <Text style={styles.alt} accessible={false}> - ALT - </Text> - </View> - )} - </AutoSizedImage> - </View> + <ContentHider modui={moderation?.ui('contentMedia')}> + <View style={[styles.imagesContainer, style]}> + <AutoSizedImage + alt={alt} + uri={thumb} + dimensionsHint={aspectRatio} + onPress={() => _openLightbox(0)} + onPressIn={() => onPressIn(0)} + style={[styles.singleImage]}> + {alt === '' ? null : ( + <View style={styles.altContainer}> + <Text style={styles.alt} accessible={false}> + ALT + </Text> + </View> + )} + </AutoSizedImage> + </View> + </ContentHider> ) } return ( - <View style={[styles.imagesContainer, style]}> - <ImageLayoutGrid - images={embed.images} - onPress={_openLightbox} - onPressIn={onPressIn} - style={embed.images.length === 1 ? [styles.singleImage] : undefined} - /> - </View> + <ContentHider modui={moderation?.ui('contentMedia')}> + <View style={[styles.imagesContainer, style]}> + <ImageLayoutGrid + images={embed.images} + onPress={_openLightbox} + onPressIn={onPressIn} + style={ + embed.images.length === 1 ? [styles.singleImage] : undefined + } + /> + </View> + </ContentHider> ) } } @@ -171,15 +163,17 @@ export function PostEmbeds({ const link = embed.external return ( - <Link - asAnchor - anchorNoUnderline - href={link.uri} - style={[styles.extOuter, pal.view, pal.borderDark, style]} - hoverStyle={{borderColor: pal.colors.borderLinkHover}} - onLongPress={onShareExternal}> - <ExternalLinkEmbed link={link} /> - </Link> + <ContentHider modui={moderation?.ui('contentMedia')}> + <Link + asAnchor + anchorNoUnderline + href={link.uri} + style={[styles.extOuter, pal.view, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} + onLongPress={onShareExternal}> + <ExternalLinkEmbed link={link} /> + </Link> + </ContentHider> ) } diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx new file mode 100644 index 000000000..64f2376a4 --- /dev/null +++ b/src/view/screens/DebugMod.tsx @@ -0,0 +1,923 @@ +import React from 'react' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {View} from 'react-native' +import { + LABELS, + mock, + moderatePost, + moderateProfile, + ModerationOpts, + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyFeedPost, + LabelPreference, + ModerationDecision, + ModerationBehavior, + RichText, + ComAtprotoLabelDefs, + interpretLabelValueDefinition, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {moderationOptsOverrideContext} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {FeedNotification} from '#/state/queries/notifications/types' +import { + groupNotifications, + shouldFilterNotif, +} from '#/state/queries/notifications/util' + +import {atoms as a, useTheme} from '#/alf' +import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {H1, H3, P, Text} from '#/components/Typography' +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import * as Toggle from '#/components/forms/Toggle' +import * as ToggleButton from '#/components/forms/ToggleButton' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom, + ChevronTop_Stroke2_Corner0_Rounded as ChevronTop, +} from '#/components/icons/Chevron' +import {ScreenHider} from '../../components/moderation/ScreenHider' +import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard' +import {ProfileCard} from '../com/profile/ProfileCard' +import {FeedItem} from '../com/posts/FeedItem' +import {FeedItem as NotifFeedItem} from '../com/notifications/FeedItem' +import {PostThreadItem} from '../com/post-thread/PostThreadItem' +import {Divider} from '#/components/Divider' + +const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys( + LABELS, +) as (keyof typeof LABELS)[] + +export const DebugModScreen = ({}: NativeStackScreenProps< + CommonNavigatorParams, + 'DebugMod' +>) => { + const t = useTheme() + const [scenario, setScenario] = React.useState<string[]>(['label']) + const [scenarioSwitches, setScenarioSwitches] = React.useState<string[]>([]) + const [label, setLabel] = React.useState<string[]>([LABEL_VALUES[0]]) + const [target, setTarget] = React.useState<string[]>(['account']) + const [visibility, setVisiblity] = React.useState<string[]>(['warn']) + const [customLabelDef, setCustomLabelDef] = + React.useState<ComAtprotoLabelDefs.LabelValueDefinition>({ + identifier: 'custom', + blurs: 'content', + severity: 'alert', + defaultSetting: 'warn', + locales: [ + { + lang: 'en', + name: 'Custom label', + description: 'A custom label created in this test environment', + }, + ], + }) + const [view, setView] = React.useState<string[]>(['post']) + const labelStrings = useGlobalLabelStrings() + const {currentAccount} = useSession() + + const isTargetMe = + scenario[0] === 'label' && scenarioSwitches.includes('targetMe') + const isSelfLabel = + scenario[0] === 'label' && scenarioSwitches.includes('selfLabel') + const noAdult = + scenario[0] === 'label' && scenarioSwitches.includes('noAdult') + const isLoggedOut = + scenario[0] === 'label' && scenarioSwitches.includes('loggedOut') + const isFollowing = scenarioSwitches.includes('following') + + const did = + isTargetMe && currentAccount ? currentAccount.did : 'did:web:bob.test' + + const profile = React.useMemo(() => { + const mockedProfile = mock.profileViewBasic({ + handle: `bob.test`, + displayName: 'Bob Robertson', + description: 'User with this as their bio', + labels: + scenario[0] === 'label' && target[0] === 'account' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/`, + }), + ] + : scenario[0] === 'label' && target[0] === 'profile' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.actor.profile/self`, + }), + ] + : undefined, + viewer: mock.actorViewerState({ + following: isFollowing + ? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234` + : undefined, + muted: scenario[0] === 'mute', + mutedByList: undefined, + blockedBy: undefined, + blocking: + scenario[0] === 'block' + ? `at://did:web:alice.test/app.bsky.actor.block/fake` + : undefined, + blockingByList: undefined, + }), + }) + mockedProfile.did = did + mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png' + mockedProfile.banner = + 'https://bsky.social/about/images/social-card-default-gradient.png' + return mockedProfile + }, [scenario, target, label, isSelfLabel, did, isFollowing, currentAccount]) + + const post = React.useMemo(() => { + return mock.postView({ + record: mock.post({ + text: "This is the body of the post. It's where the text goes. You get the idea.", + }), + author: profile, + labels: + scenario[0] === 'label' && target[0] === 'post' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + embed: + target[0] === 'embed' + ? mock.embedRecordView({ + record: mock.post({ + text: 'Embed', + }), + labels: + scenario[0] === 'label' && target[0] === 'embed' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + author: profile, + }) + : { + $type: 'app.bsky.embed.images#view', + images: [ + { + thumb: + 'https://bsky.social/about/images/social-card-default-gradient.png', + fullsize: + 'https://bsky.social/about/images/social-card-default-gradient.png', + alt: '', + }, + ], + }, + }) + }, [scenario, label, target, profile, isSelfLabel, did]) + + const replyNotif = React.useMemo(() => { + const notif = mock.replyNotification({ + record: mock.post({ + text: "This is the body of the post. It's where the text goes. You get the idea.", + reply: { + parent: { + uri: `at://${did}/app.bsky.feed.post/fake-parent`, + cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq', + }, + root: { + uri: `at://${did}/app.bsky.feed.post/fake-parent`, + cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq', + }, + }, + }), + author: profile, + labels: + scenario[0] === 'label' && target[0] === 'post' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + }) + const [item] = groupNotifications([notif]) + item.subject = mock.postView({ + record: notif.record as AppBskyFeedPost.Record, + author: profile, + labels: notif.labels, + }) + return item + }, [scenario, label, target, profile, isSelfLabel, did]) + + const followNotif = React.useMemo(() => { + const notif = mock.followNotification({ + author: profile, + subjectDid: currentAccount?.did || '', + }) + const [item] = groupNotifications([notif]) + return item + }, [profile, currentAccount]) + + const modOpts = React.useMemo(() => { + return { + userDid: isLoggedOut ? '' : isTargetMe ? did : 'did:web:alice.test', + prefs: { + adultContentEnabled: !noAdult, + labels: { + [label[0]]: visibility[0] as LabelPreference, + }, + labelers: [ + { + did: 'did:plc:fake-labeler', + labels: {[label[0]]: visibility[0] as LabelPreference}, + }, + ], + mutedWords: [], + hiddenPosts: [], + }, + labelDefs: { + 'did:plc:fake-labeler': [ + interpretLabelValueDefinition(customLabelDef, 'did:plc:fake-labeler'), + ], + }, + } + }, [label, visibility, noAdult, isLoggedOut, isTargetMe, did, customLabelDef]) + + const profileModeration = React.useMemo(() => { + return moderateProfile(profile, modOpts) + }, [profile, modOpts]) + const postModeration = React.useMemo(() => { + return moderatePost(post, modOpts) + }, [post, modOpts]) + + return ( + <moderationOptsOverrideContext.Provider value={modOpts}> + <ScrollView> + <CenteredView style={[t.atoms.bg, a.px_lg, a.py_lg]}> + <H1 style={[a.text_5xl, a.font_bold, a.pb_lg]}>Moderation states</H1> + + <Heading title="" subtitle="Scenario" /> + <ToggleButton.Group + label="Scenario" + values={scenario} + onChange={setScenario}> + <ToggleButton.Button name="label" label="Label"> + Label + </ToggleButton.Button> + <ToggleButton.Button name="block" label="Block"> + Block + </ToggleButton.Button> + <ToggleButton.Button name="mute" label="Mute"> + Mute + </ToggleButton.Button> + </ToggleButton.Group> + + {scenario[0] === 'label' && ( + <> + <View + style={[ + a.border, + a.rounded_sm, + a.mt_lg, + a.mb_lg, + a.p_lg, + t.atoms.border_contrast_medium, + ]}> + <Toggle.Group + label="Toggle" + type="radio" + values={label} + onChange={setLabel}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + {LABEL_VALUES.map(labelValue => { + let targetFixed = target[0] + if ( + targetFixed !== 'account' && + targetFixed !== 'profile' + ) { + targetFixed = 'content' + } + const disabled = + isSelfLabel && + LABELS[labelValue].flags.includes('no-self') + return ( + <Toggle.Item + key={labelValue} + name={labelValue} + label={labelStrings[labelValue].name} + disabled={disabled} + style={disabled ? {opacity: 0.5} : undefined}> + <Toggle.Radio /> + <Toggle.Label>{labelValue}</Toggle.Label> + </Toggle.Item> + ) + })} + <Toggle.Item + name="custom" + label="Custom label" + disabled={isSelfLabel} + style={isSelfLabel ? {opacity: 0.5} : undefined}> + <Toggle.Radio /> + <Toggle.Label>Custom label</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + {label[0] === 'custom' ? ( + <CustomLabelForm + def={customLabelDef} + setDef={setCustomLabelDef} + /> + ) : ( + <> + <View style={{height: 10}} /> + <Divider /> + </> + )} + + <View style={{height: 10}} /> + + <SmallToggler label="Advanced"> + <Toggle.Group + label="Toggle" + type="checkbox" + values={scenarioSwitches} + onChange={setScenarioSwitches}> + <View style={[a.gap_md, a.flex_row, a.flex_wrap, a.pt_md]}> + <Toggle.Item name="targetMe" label="Target is me"> + <Toggle.Checkbox /> + <Toggle.Label>Target is me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="following" label="Following target"> + <Toggle.Checkbox /> + <Toggle.Label>Following target</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="selfLabel" label="Self label"> + <Toggle.Checkbox /> + <Toggle.Label>Self label</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="noAdult" label="Adult disabled"> + <Toggle.Checkbox /> + <Toggle.Label>Adult disabled</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="loggedOut" label="Logged out"> + <Toggle.Checkbox /> + <Toggle.Label>Logged out</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + {LABELS[label[0] as keyof typeof LABELS]?.configurable !== + false && ( + <View style={[a.mt_md]}> + <Text + style={[a.font_bold, a.text_xs, t.atoms.text, a.pb_sm]}> + Preference + </Text> + <Toggle.Group + label="Preference" + type="radio" + values={visibility} + onChange={setVisiblity}> + <View + style={[ + a.flex_row, + a.gap_md, + a.flex_wrap, + a.align_center, + ]}> + <Toggle.Item name="hide" label="Hide"> + <Toggle.Radio /> + <Toggle.Label>Hide</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="warn" label="Warn"> + <Toggle.Radio /> + <Toggle.Label>Warn</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="ignore" label="Ignore"> + <Toggle.Radio /> + <Toggle.Label>Ignore</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + )} + </SmallToggler> + </View> + + <View style={[a.flex_row, a.flex_wrap, a.gap_md]}> + <View> + <Text + style={[ + a.font_bold, + a.text_xs, + t.atoms.text, + a.pl_md, + a.pb_xs, + ]}> + Target + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Target" + type="radio" + values={target} + onChange={setTarget}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + <Toggle.Item name="account" label="Account"> + <Toggle.Radio /> + <Toggle.Label>Account</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="profile" label="Profile"> + <Toggle.Radio /> + <Toggle.Label>Profile</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="post" label="Post"> + <Toggle.Radio /> + <Toggle.Label>Post</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="embed" label="Embed"> + <Toggle.Radio /> + <Toggle.Label>Embed</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + </View> + </> + )} + + <Spacer /> + + <Heading title="" subtitle="Results" /> + + <ToggleButton.Group label="Results" values={view} onChange={setView}> + <ToggleButton.Button name="post" label="Post"> + Post + </ToggleButton.Button> + <ToggleButton.Button name="notifications" label="Notifications"> + Notifications + </ToggleButton.Button> + <ToggleButton.Button name="account" label="Account"> + Account + </ToggleButton.Button> + <ToggleButton.Button name="data" label="Data"> + Data + </ToggleButton.Button> + </ToggleButton.Group> + + <View + style={[ + a.border, + a.rounded_sm, + a.mt_lg, + a.p_md, + t.atoms.border_contrast_medium, + ]}> + {view[0] === 'post' && ( + <> + <Heading title="Post" subtitle="in feed" /> + <MockPostFeedItem post={post} moderation={postModeration} /> + + <Heading title="Post" subtitle="viewed directly" /> + <MockPostThreadItem post={post} moderation={postModeration} /> + + <Heading title="Post" subtitle="reply in thread" /> + <MockPostThreadItem + post={post} + moderation={postModeration} + reply + /> + </> + )} + + {view[0] === 'notifications' && ( + <> + <Heading title="Notification" subtitle="quote or reply" /> + <MockNotifItem notif={replyNotif} moderationOpts={modOpts} /> + <View style={{height: 20}} /> + <Heading title="Notification" subtitle="follow or like" /> + <MockNotifItem notif={followNotif} moderationOpts={modOpts} /> + </> + )} + + {view[0] === 'account' && ( + <> + <Heading title="Account" subtitle="in listing" /> + <MockAccountCard + profile={profile} + moderation={profileModeration} + /> + + <Heading title="Account" subtitle="viewing directly" /> + <MockAccountScreen + profile={profile} + moderation={profileModeration} + moderationOpts={modOpts} + /> + </> + )} + + {view[0] === 'data' && ( + <> + <ModerationUIView + label="Profile Moderation UI" + mod={profileModeration} + /> + <ModerationUIView + label="Post Moderation UI" + mod={postModeration} + /> + <DataView + label={label[0]} + data={LABELS[label[0] as keyof typeof LABELS]} + /> + <DataView + label="Profile Moderation Data" + data={profileModeration} + /> + <DataView label="Post Moderation Data" data={postModeration} /> + </> + )} + </View> + + <View style={{height: 400}} /> + </CenteredView> + </ScrollView> + </moderationOptsOverrideContext.Provider> + ) +} + +function Heading({title, subtitle}: {title: string; subtitle?: string}) { + const t = useTheme() + return ( + <H3 style={[a.text_3xl, a.font_bold, a.pb_md]}> + {title}{' '} + {!!subtitle && ( + <H3 style={[t.atoms.text_contrast_medium, a.text_lg]}>{subtitle}</H3> + )} + </H3> + ) +} + +function CustomLabelForm({ + def, + setDef, +}: { + def: ComAtprotoLabelDefs.LabelValueDefinition + setDef: React.Dispatch< + React.SetStateAction<ComAtprotoLabelDefs.LabelValueDefinition> + > +}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_row, + a.flex_wrap, + a.gap_md, + t.atoms.bg_contrast_25, + a.rounded_md, + a.p_md, + a.mt_md, + ]}> + <View> + <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}> + Blurs + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Blurs" + type="radio" + values={[def.blurs]} + onChange={values => setDef(v => ({...v, blurs: values[0]}))}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + <Toggle.Item name="content" label="Content"> + <Toggle.Radio /> + <Toggle.Label>Content</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="media" label="Media"> + <Toggle.Radio /> + <Toggle.Label>Media</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="none" label="None"> + <Toggle.Radio /> + <Toggle.Label>None</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + <View> + <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}> + Severity + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Severity" + type="radio" + values={[def.severity]} + onChange={values => setDef(v => ({...v, severity: values[0]}))}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap, a.align_center]}> + <Toggle.Item name="alert" label="Alert"> + <Toggle.Radio /> + <Toggle.Label>Alert</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="inform" label="Inform"> + <Toggle.Radio /> + <Toggle.Label>Inform</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="none" label="None"> + <Toggle.Radio /> + <Toggle.Label>None</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + </View> + ) +} + +function Toggler({label, children}: React.PropsWithChildren<{label: string}>) { + const t = useTheme() + const [show, setShow] = React.useState(false) + return ( + <View style={a.mb_md}> + <View + style={[ + t.atoms.border_contrast_medium, + a.border, + a.rounded_sm, + a.p_xs, + ]}> + <Button + variant="solid" + color="secondary" + label="Toggle visibility" + size="small" + onPress={() => setShow(!show)}> + <ButtonText>{label}</ButtonText> + <ButtonIcon + icon={show ? ChevronTop : ChevronBottom} + position="right" + /> + </Button> + {show && children} + </View> + </View> + ) +} + +function SmallToggler({ + label, + children, +}: React.PropsWithChildren<{label: string}>) { + const [show, setShow] = React.useState(false) + return ( + <View> + <View style={[a.flex_row]}> + <Button + variant="ghost" + color="secondary" + label="Toggle visibility" + size="tiny" + onPress={() => setShow(!show)}> + <ButtonText>{label}</ButtonText> + <ButtonIcon + icon={show ? ChevronTop : ChevronBottom} + position="right" + /> + </Button> + </View> + {show && children} + </View> + ) +} + +function DataView({label, data}: {label: string; data: any}) { + return ( + <Toggler label={label}> + <Text style={[{fontFamily: 'monospace'}, a.p_md]}> + {JSON.stringify(data, null, 2)} + </Text> + </Toggler> + ) +} + +function ModerationUIView({ + mod, + label, +}: { + mod: ModerationDecision + label: string +}) { + return ( + <Toggler label={label}> + <View style={a.p_lg}> + {[ + 'profileList', + 'profileView', + 'avatar', + 'banner', + 'displayName', + 'contentList', + 'contentView', + 'contentMedia', + ].map(key => { + const ui = mod.ui(key as keyof ModerationBehavior) + return ( + <View key={key} style={[a.flex_row, a.gap_md]}> + <Text style={[a.font_bold, {width: 100}]}>{key}</Text> + <Flag v={ui.filter} label="Filter" /> + <Flag v={ui.blur} label="Blur" /> + <Flag v={ui.alert} label="Alert" /> + <Flag v={ui.inform} label="Inform" /> + <Flag v={ui.noOverride} label="No-override" /> + </View> + ) + })} + </View> + </Toggler> + ) +} + +function Spacer() { + return <View style={{height: 30}} /> +} + +function MockPostFeedItem({ + post, + moderation, +}: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision +}) { + const t = useTheme() + if (moderation.ui('contentList').filter) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}> + Filtered from the feed + </P> + ) + } + return ( + <FeedItem + post={post} + record={post.record as AppBskyFeedPost.Record} + moderation={moderation} + reason={undefined} + /> + ) +} + +function MockPostThreadItem({ + post, + reply, +}: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision + reply?: boolean +}) { + return ( + <PostThreadItem + // @ts-ignore + post={post} + record={post.record as AppBskyFeedPost.Record} + depth={reply ? 1 : 0} + isHighlightedPost={!reply} + treeView={false} + prevPost={undefined} + nextPost={undefined} + hasPrecedingItem={false} + onPostReply={() => {}} + /> + ) +} + +function MockNotifItem({ + notif, + moderationOpts, +}: { + notif: FeedNotification + moderationOpts: ModerationOpts +}) { + const t = useTheme() + if (shouldFilterNotif(notif.notification, moderationOpts)) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md]}> + Filtered from the feed + </P> + ) + } + return <NotifFeedItem item={notif} moderationOpts={moderationOpts} /> +} + +function MockAccountCard({ + profile, + moderation, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ModerationDecision +}) { + const t = useTheme() + + if (moderation.ui('profileList').filter) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}> + Filtered from the listing + </P> + ) + } + + return <ProfileCard profile={profile} /> +} + +function MockAccountScreen({ + profile, + moderation, + moderationOpts, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ModerationDecision + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const {_} = useLingui() + return ( + <View style={[t.atoms.border_contrast_medium, a.border, a.mb_md]}> + <ScreenHider + style={{}} + screenDescription={_(msg`profile`)} + modui={moderation.ui('profileView')}> + <ProfileHeaderStandard + // @ts-ignore ProfileViewBasic is close enough -prf + profile={profile} + moderationOpts={moderationOpts} + descriptionRT={new RichText({text: profile.description as string})} + /> + </ScreenHider> + </View> + ) +} + +function Flag({v, label}: {v: boolean | undefined; label: string}) { + const t = useTheme() + return ( + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <View + style={[ + a.justify_center, + a.align_center, + a.rounded_xs, + a.border, + t.atoms.border_contrast_medium, + { + backgroundColor: t.palette.contrast_25, + width: 14, + height: 14, + }, + ]}> + {v && <Check size="xs" fill={t.palette.contrast_900} />} + </View> + <P style={a.text_xs}>{label}</P> + </View> + ) +} diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx deleted file mode 100644 index 2848905c6..000000000 --- a/src/view/screens/Moderation.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {ComAtprotoLabelDefs} from '@atproto/api' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {s} from 'lib/styles' -import {CenteredView} from '../com/util/Views' -import {ViewHeader} from '../com/util/ViewHeader' -import {Link, TextLink} from '../com/util/Link' -import {Text} from '../com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useSetMinimalShellMode} from '#/state/shell' -import {useModalControls} from '#/state/modals' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {ToggleButton} from '../com/util/forms/ToggleButton' -import {useSession} from '#/state/session' -import { - useProfileQuery, - useProfileUpdateMutation, -} from '#/state/queries/profile' -import {ScrollView} from '../com/util/Views' -import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' - -type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> -export function ModerationScreen({}: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() - const {openModal} = useModalControls() - const {mutedWordsDialogControl} = useGlobalDialogsControlContext() - - useFocusEffect( - React.useCallback(() => { - screen('Moderation') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) - - const onPressContentFiltering = React.useCallback(() => { - track('Moderation:ContentfilteringButtonClicked') - openModal({name: 'content-filtering-settings'}) - }, [track, openModal]) - - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, - ]} - testID="moderationScreen"> - <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> - <ScrollView contentContainerStyle={[styles.noBorder]}> - <View style={styles.spacer} /> - <TouchableOpacity - testID="contentFilteringBtn" - style={[styles.linkCard, pal.view]} - onPress={onPressContentFiltering} - accessibilityRole="tab" - accessibilityLabel={_(msg`Content filtering`)} - accessibilityHint={_( - msg`Opens modal for content filtering settings`, - )}> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="eye" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Content filtering</Trans> - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="mutedWordsBtn" - style={[styles.linkCard, pal.view]} - onPress={() => mutedWordsDialogControl.open()} - accessibilityRole="tab" - accessibilityLabel={_(msg`Muted words & tags`)} - accessibilityHint={_(msg`Open modal for muted words settings`)}> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="filter" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Muted words & tags</Trans> - </Text> - </TouchableOpacity> - <Link - testID="moderationlistsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/modlists"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="users-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Moderation lists</Trans> - </Text> - </Link> - <Link - testID="mutedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/muted-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="user-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Muted accounts</Trans> - </Text> - </Link> - <Link - testID="blockedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/blocked-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="ban" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Blocked accounts</Trans> - </Text> - </Link> - <Text - type="xl-bold" - style={[ - pal.text, - { - paddingHorizontal: 18, - paddingTop: 18, - paddingBottom: 6, - }, - ]}> - <Trans>Logged-out visibility</Trans> - </Text> - <PwiOptOut /> - </ScrollView> - </CenteredView> - ) -} - -function PwiOptOut() { - const pal = usePalette('default') - const {_} = useLingui() - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const updateProfile = useProfileUpdateMutation() - - const isOptedOut = - profile?.labels?.some(l => l.val === '!no-unauthenticated') || false - const canToggle = profile && !updateProfile.isPending - - const onToggleOptOut = React.useCallback(() => { - if (!profile) { - return - } - let wasAdded = false - updateProfile.mutate({ - profile, - updates: existing => { - // create labels attr if needed - existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) - ? existing.labels - : { - $type: 'com.atproto.label.defs#selfLabels', - values: [], - } - - // toggle the label - const hasLabel = existing.labels.values.some( - l => l.val === '!no-unauthenticated', - ) - if (hasLabel) { - wasAdded = false - existing.labels.values = existing.labels.values.filter( - l => l.val !== '!no-unauthenticated', - ) - } else { - wasAdded = true - existing.labels.values.push({val: '!no-unauthenticated'}) - } - - // delete if no longer needed - if (existing.labels.values.length === 0) { - delete existing.labels - } - return existing - }, - checkCommitted: res => { - const exists = !!res.data.labels?.some( - l => l.val === '!no-unauthenticated', - ) - return exists === wasAdded - }, - }) - }, [updateProfile, profile]) - - return ( - <View style={[pal.view, styles.toggleCard]}> - <View - style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}> - <ToggleButton - type="default-light" - label={_( - msg`Discourage apps from showing my account to logged-out users`, - )} - labelType="lg" - isSelected={isOptedOut} - onPress={canToggle ? onToggleOptOut : undefined} - style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]} - /> - {updateProfile.isPending && <ActivityIndicator />} - </View> - <View - style={{ - flexDirection: 'column', - gap: 10, - paddingLeft: 66, - paddingRight: 12, - paddingBottom: 10, - marginBottom: 64, - }}> - <Text style={pal.textLight}> - <Trans> - Bluesky will not show your profile and posts to logged-out users. - Other apps may not honor this request. This does not make your - account private. - </Trans> - </Text> - <Text style={[pal.textLight, {fontWeight: '500'}]}> - <Trans> - Note: Bluesky is an open and public network. This setting only - limits the visibility of your content on the Bluesky app and - website, and other apps may not respect this setting. Your content - may still be shown to logged-out users by other apps and websites. - </Trans> - </Text> - <TextLink - style={pal.link} - href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy" - text={_(msg`Learn more about what is public on Bluesky.`)} - /> - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - spacer: { - height: 6, - }, - linkCard: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 18, - marginBottom: 1, - }, - toggleCard: { - paddingVertical: 8, - paddingTop: 2, - paddingHorizontal: 6, - marginBottom: 1, - }, - iconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 40, - height: 40, - borderRadius: 30, - marginRight: 12, - }, - noBorder: { - borderBottomWidth: 0, - borderRightWidth: 0, - borderLeftWidth: 0, - borderTopWidth: 0, - }, -}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index b30b4491b..d5a46c5c9 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from 'react' -import {StyleSheet, View} from 'react-native' +import {StyleSheet} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import { AppBskyActorDefs, @@ -7,48 +7,39 @@ import { ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {CenteredView} from '../com/util/Views' import {ListRef} from '../com/util/List' -import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {Feed} from 'view/com/posts/Feed' +import {ScreenHider} from '#/components/moderation/ScreenHider' import {ProfileLists} from '../com/lists/ProfileLists' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' -import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ErrorScreen} from '../com/util/error/ErrorScreen' -import {EmptyState} from '../com/util/EmptyState' import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' -import { - FeedDescriptor, - resetProfilePostsQueries, -} from '#/state/queries/post-feed' +import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useProfileQuery} from '#/state/queries/profile' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession, getAgent} from '#/state/session' import {useModerationOpts} from '#/state/queries/preferences' -import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {useLabelerInfoQuery} from '#/state/queries/labeler' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {cleanError} from '#/lib/strings/errors' -import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' -import {useQueryClient} from '@tanstack/react-query' import {useComposerControls} from '#/state/shell/composer' import {listenSoftReset} from '#/state/events' -import {truncateAndInvalidate} from '#/state/queries/util' -import {Text} from '#/view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {isNative} from '#/platform/detection' import {isInvalidHandle} from '#/lib/strings/handles' +import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' +import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' +import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' + interface SectionRef { scrollToTop: () => void } @@ -148,16 +139,24 @@ function ProfileScreenLoaded({ const setMinimalShellMode = useSetMinimalShellMode() const {openComposer} = useComposerControls() const {screen, track} = useAnalytics() + const { + data: labelerInfo, + error: labelerError, + isLoading: isLabelerLoading, + } = useLabelerInfoQuery({ + did: profile.did, + enabled: !!profile.associated?.labeler, + }) const [currentPage, setCurrentPage] = React.useState(0) const {_} = useLingui() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const extraInfoQuery = useProfileExtraInfoQuery(profile.did) const postsSectionRef = React.useRef<SectionRef>(null) const repliesSectionRef = React.useRef<SectionRef>(null) const mediaSectionRef = React.useRef<SectionRef>(null) const likesSectionRef = React.useRef<SectionRef>(null) const feedsSectionRef = React.useRef<SectionRef>(null) const listsSectionRef = React.useRef<SectionRef>(null) + const labelsSectionRef = React.useRef<SectionRef>(null) useSetTitle(combinedDisplayName(profile)) @@ -171,44 +170,75 @@ function ProfileScreenLoaded({ ) const isMe = profile.did === currentAccount?.did + const hasLabeler = !!profile.associated?.labeler + const showFiltersTab = hasLabeler + const showPostsTab = true const showRepliesTab = hasSession + const showMediaTab = !hasLabeler const showLikesTab = isMe - const showFeedsTab = hasSession && (isMe || extraInfoQuery.data?.hasFeedgens) - const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists) + const showFeedsTab = + hasSession && (isMe || (profile.associated?.feedgens || 0) > 0) + const showListsTab = + hasSession && (isMe || (profile.associated?.lists || 0) > 0) + const sectionTitles = useMemo<string[]>(() => { return [ - _(msg`Posts`), + showFiltersTab ? _(msg`Labels`) : undefined, + showListsTab && hasLabeler ? _(msg`Lists`) : undefined, + showPostsTab ? _(msg`Posts`) : undefined, showRepliesTab ? _(msg`Replies`) : undefined, - _(msg`Media`), + showMediaTab ? _(msg`Media`) : undefined, showLikesTab ? _(msg`Likes`) : undefined, showFeedsTab ? _(msg`Feeds`) : undefined, - showListsTab ? _(msg`Lists`) : undefined, + showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, ].filter(Boolean) as string[] - }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab, _]) + }, [ + showPostsTab, + showRepliesTab, + showMediaTab, + showLikesTab, + showFeedsTab, + showListsTab, + showFiltersTab, + hasLabeler, + _, + ]) let nextIndex = 0 - const postsIndex = nextIndex++ + let filtersIndex: number | null = null + let postsIndex: number | null = null let repliesIndex: number | null = null + let mediaIndex: number | null = null + let likesIndex: number | null = null + let feedsIndex: number | null = null + let listsIndex: number | null = null + if (showFiltersTab) { + filtersIndex = nextIndex++ + } + if (showPostsTab) { + postsIndex = nextIndex++ + } if (showRepliesTab) { repliesIndex = nextIndex++ } - const mediaIndex = nextIndex++ - let likesIndex: number | null = null + if (showMediaTab) { + mediaIndex = nextIndex++ + } if (showLikesTab) { likesIndex = nextIndex++ } - let feedsIndex: number | null = null if (showFeedsTab) { feedsIndex = nextIndex++ } - let listsIndex: number | null = null if (showListsTab) { listsIndex = nextIndex++ } const scrollSectionToTop = React.useCallback( (index: number) => { - if (index === postsIndex) { + if (index === filtersIndex) { + labelsSectionRef.current?.scrollToTop() + } else if (index === postsIndex) { postsSectionRef.current?.scrollToTop() } else if (index === repliesIndex) { repliesSectionRef.current?.scrollToTop() @@ -222,7 +252,15 @@ function ProfileScreenLoaded({ listsSectionRef.current?.scrollToTop() } }, - [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex], + [ + filtersIndex, + postsIndex, + repliesIndex, + mediaIndex, + likesIndex, + feedsIndex, + listsIndex, + ], ) useFocusEffect( @@ -278,6 +316,7 @@ function ProfileScreenLoaded({ return ( <ProfileHeader profile={profile} + labeler={labelerInfo} descriptionRT={hasDescription ? descriptionRT : null} moderationOpts={moderationOpts} hideBackButton={hideBackButton} @@ -286,6 +325,7 @@ function ProfileScreenLoaded({ ) }, [ profile, + labelerInfo, descriptionRT, hasDescription, moderationOpts, @@ -297,8 +337,8 @@ function ProfileScreenLoaded({ <ScreenHider testID="profileView" style={styles.container} - screenDescription="profile" - moderation={moderation.account}> + screenDescription={_(msg`profile`)} + modui={moderation.ui('profileView')}> <PagerWithHeader testID="profilePager" isHeaderReady={!showPlaceholder} @@ -306,19 +346,45 @@ function ProfileScreenLoaded({ onPageSelected={onPageSelected} onCurrentPageSelected={onCurrentPageSelected} renderHeader={renderHeader}> - {({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection - ref={postsSectionRef} - feed={`author|${profile.did}|posts_and_author_threads`} - headerHeight={headerHeight} - isFocused={isFocused} - scrollElRef={scrollElRef as ListRef} - ignoreFilterFor={profile.did} - /> - )} + {showFiltersTab + ? ({headerHeight, scrollElRef}) => ( + <ProfileLabelsSection + ref={labelsSectionRef} + labelerInfo={labelerInfo} + labelerError={labelerError} + isLabelerLoading={isLabelerLoading} + moderationOpts={moderationOpts} + scrollElRef={scrollElRef as ListRef} + headerHeight={headerHeight} + /> + ) + : null} + {showListsTab && !!profile.associated?.labeler + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileLists + ref={listsSectionRef} + did={profile.did} + scrollElRef={scrollElRef as ListRef} + headerOffset={headerHeight} + enabled={isFocused} + /> + ) + : null} + {showPostsTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedSection + ref={postsSectionRef} + feed={`author|${profile.did}|posts_and_author_threads`} + headerHeight={headerHeight} + isFocused={isFocused} + scrollElRef={scrollElRef as ListRef} + ignoreFilterFor={profile.did} + /> + ) + : null} {showRepliesTab ? ({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection + <ProfileFeedSection ref={repliesSectionRef} feed={`author|${profile.did}|posts_with_replies`} headerHeight={headerHeight} @@ -328,19 +394,21 @@ function ProfileScreenLoaded({ /> ) : null} - {({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection - ref={mediaSectionRef} - feed={`author|${profile.did}|posts_with_media`} - headerHeight={headerHeight} - isFocused={isFocused} - scrollElRef={scrollElRef as ListRef} - ignoreFilterFor={profile.did} - /> - )} + {showMediaTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedSection + ref={mediaSectionRef} + feed={`author|${profile.did}|posts_with_media`} + headerHeight={headerHeight} + isFocused={isFocused} + scrollElRef={scrollElRef as ListRef} + ignoreFilterFor={profile.did} + /> + ) + : null} {showLikesTab ? ({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection + <ProfileFeedSection ref={likesSectionRef} feed={`likes|${profile.did}`} headerHeight={headerHeight} @@ -361,7 +429,7 @@ function ProfileScreenLoaded({ /> ) : null} - {showListsTab + {showListsTab && !profile.associated?.labeler ? ({headerHeight, isFocused, scrollElRef}) => ( <ProfileLists ref={listsSectionRef} @@ -387,77 +455,6 @@ function ProfileScreenLoaded({ ) } -interface FeedSectionProps { - feed: FeedDescriptor - headerHeight: number - isFocused: boolean - scrollElRef: ListRef - ignoreFilterFor?: string -} -const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( - function FeedSectionImpl( - {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, - ref, - ) { - const {_} = useLingui() - const queryClient = useQueryClient() - const [hasNew, setHasNew] = React.useState(false) - const [isScrolledDown, setIsScrolledDown] = React.useState(false) - - const onScrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({ - animated: isNative, - offset: -headerHeight, - }) - truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) - setHasNew(false) - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) - React.useImperativeHandle(ref, () => ({ - scrollToTop: onScrollToTop, - })) - - const renderPostsEmpty = React.useCallback(() => { - return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> - }, [_]) - - return ( - <View> - <Feed - testID="postsFeed" - enabled={isFocused} - feed={feed} - scrollElRef={scrollElRef} - onHasNew={setHasNew} - onScrolledDownChange={setIsScrolledDown} - renderEmptyState={renderPostsEmpty} - headerOffset={headerHeight} - renderEndOfFeed={ProfileEndOfFeed} - ignoreFilterFor={ignoreFilterFor} - /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onScrollToTop} - label={_(msg`Load new posts`)} - showIndicator={hasNew} - /> - )} - </View> - ) - }, -) - -function ProfileEndOfFeed() { - const pal = usePalette('default') - - return ( - <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}> - <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}> - <Trans>End of feed</Trans> - </Text> - </View> - ) -} - function useRichText(text: string): [RichTextAPI, boolean] { const [prevText, setPrevText] = React.useState(text) const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text})) diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index 579e77f57..8eeeb5d90 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -35,7 +35,7 @@ import {ComposeIcon2} from 'lib/icons' import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import { @@ -155,7 +155,7 @@ export function ProfileFeedScreenInner({ const {_} = useLingui() const t = useTheme() const {hasSession, currentAccount} = useSession() - const {openModal} = useModalControls() + const reportDialogControl = useReportDialogControl() const {openComposer} = useComposerControls() const {track} = useAnalytics() const feedSectionRef = React.useRef<SectionRef>(null) @@ -253,13 +253,8 @@ export function ProfileFeedScreenInner({ }, [feedInfo, track]) const onPressReport = React.useCallback(() => { - if (!feedInfo) return - openModal({ - name: 'report', - uri: feedInfo.uri, - cid: feedInfo.cid, - }) - }, [openModal, feedInfo]) + reportDialogControl.open() + }, [reportDialogControl]) const onCurrentPageSelected = React.useCallback( (index: number) => { @@ -400,6 +395,14 @@ export function ProfileFeedScreenInner({ return ( <View style={s.hContentRegion}> + <ReportDialog + control={reportDialogControl} + params={{ + type: 'feedgen', + uri: feedInfo.uri, + cid: feedInfo.cid, + }} + /> <PagerWithHeader items={SECTION_TITLES} isHeaderReady={true} diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 798611157..58b89f239 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -39,6 +39,7 @@ import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' import {useModalControls} from '#/state/modals' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import { useListQuery, @@ -236,6 +237,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const {currentAccount} = useSession() + const reportDialogControl = useReportDialogControl() const {openModal} = useModalControls() const listMuteMutation = useListMuteMutation() const listBlockMutation = useListBlockMutation() @@ -370,12 +372,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { ]) const onPressReport = useCallback(() => { - openModal({ - name: 'report', - uri: list.uri, - cid: list.cid, - }) - }, [openModal, list]) + reportDialogControl.open() + }, [reportDialogControl]) const onPressShare = useCallback(() => { const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) @@ -550,6 +548,14 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { isOwner={list.creator.did === currentAccount?.did} creator={list.creator} avatarType="list"> + <ReportDialog + control={reportDialogControl} + params={{ + type: 'list', + uri: list.uri, + cid: list.cid, + }} + /> {isCurateList || isPinned ? ( <Button testID={isPinned ? 'unpinBtn' : 'pinBtn'} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 3d8d310ef..b817ee04d 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -267,6 +267,10 @@ export function SettingsScreen({}: Props) { navigation.navigate('Debug') }, [navigation]) + const onPressDebugModeration = React.useCallback(() => { + navigation.navigate('DebugMod') + }, [navigation]) + const onPressSavedFeeds = React.useCallback(() => { navigation.navigate('SavedFeeds') }, [navigation]) @@ -828,6 +832,16 @@ export function SettingsScreen({}: Props) { </TouchableOpacity> <TouchableOpacity style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressDebugModeration} + accessibilityRole="button" + accessibilityLabel={_(msg`Open storybook page`)} + accessibilityHint={_(msg`Opens the storybook page`)}> + <Text type="lg" style={pal.text}> + <Trans>Debug Moderation</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} onPress={onPressResetPreferences} accessibilityRole="button" accessibilityLabel={_(msg`Reset preferences state`)} diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx index 320db13ff..ad2fff3f4 100644 --- a/src/view/screens/Storybook/Buttons.tsx +++ b/src/view/screens/Storybook/Buttons.tsx @@ -129,6 +129,15 @@ export function Buttons() { <ButtonIcon icon={Globe} position="left" /> <ButtonText>Link out</ButtonText> </Button> + + <Button + variant="gradient" + color="gradient_sky" + size="tiny" + label="Link out"> + <ButtonIcon icon={Globe} position="left" /> + <ButtonText>Link out</ButtonText> + </Button> </View> <View style={[a.flex_row, a.gap_md, a.align_start]}> @@ -149,6 +158,14 @@ export function Buttons() { <ButtonIcon icon={ChevronLeft} /> </Button> <Button + variant="gradient" + color="gradient_sunset" + size="tiny" + shape="round" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> + <Button variant="outline" color="primary" size="large" @@ -164,6 +181,14 @@ export function Buttons() { label="Link out"> <ButtonIcon icon={ChevronLeft} /> </Button> + <Button + variant="ghost" + color="primary" + size="tiny" + shape="round" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> </View> <View style={[a.flex_row, a.gap_md, a.align_start]}> @@ -184,6 +209,14 @@ export function Buttons() { <ButtonIcon icon={ChevronLeft} /> </Button> <Button + variant="gradient" + color="gradient_sunset" + size="tiny" + shape="square" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> + <Button variant="outline" color="primary" size="large" @@ -199,6 +232,14 @@ export function Buttons() { label="Link out"> <ButtonIcon icon={ChevronLeft} /> </Button> + <Button + variant="ghost" + color="primary" + size="tiny" + shape="square" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> </View> </View> ) diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index e43d756de..3a2e2f369 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -67,6 +67,7 @@ export function Storybook() { </Button> </View> + <Dialogs /> <ThemeProvider theme="light"> <Theming /> </ThemeProvider> diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 4a9483733..8933324ee 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -11,7 +11,7 @@ import {useNavigation, StackActions} from '@react-navigation/native' import { AppBskyActorDefs, moderateProfile, - ProfileModeration, + ModerationDecision, } from '@atproto/api' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -86,7 +86,7 @@ export function SearchProfileCard({ moderation, }: { profile: AppBskyActorDefs.ProfileViewBasic - moderation: ProfileModeration + moderation: ModerationDecision }) { const pal = usePalette('default') @@ -111,7 +111,7 @@ export function SearchProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> <View style={{flex: 1}}> <Text @@ -121,7 +121,7 @@ export function SearchProfileCard({ lineHeight={1.2}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 76a7f8fb3..f29183095 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -101,8 +101,8 @@ function ShellInner() { <Composer winHeight={winDim.height} /> <ModalsContainer /> <MutedWordsDialog /> - <PortalOutlet /> <Lightbox /> + <PortalOutlet /> </> ) } diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 71dccb8c4..02993ac46 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -1,5 +1,9 @@ import React, {useEffect} from 'react' import {View, StyleSheet, TouchableOpacity} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + import {ErrorBoundary} from '../com/util/ErrorBoundary' import {Lightbox} from '../com/lightbox/Lightbox' import {ModalsContainer} from '../com/modals/Modal' @@ -9,9 +13,7 @@ import {s, colors} from 'lib/styles' import {RoutesContainer, FlatNavigator} from '../../Navigation' import {DrawerContent} from './Drawer' import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries' -import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' -import {t} from '@lingui/macro' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useCloseAllActiveElements} from '#/state/util' import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' @@ -24,6 +26,7 @@ function ShellInner() { const {isDesktop} = useWebMediaQueries() const navigator = useNavigation<NavigationProp>() const closeAllActiveElements = useCloseAllActiveElements() + const {_} = useLingui() useWebBodyScrollLock(isDrawerOpen) @@ -42,14 +45,15 @@ function ShellInner() { <Composer winHeight={0} /> <ModalsContainer /> <MutedWordsDialog /> - <PortalOutlet /> <Lightbox /> + <PortalOutlet /> + {!isDesktop && isDrawerOpen && ( <TouchableOpacity onPress={() => setDrawerOpen(false)} style={styles.drawerMask} - accessibilityLabel={t`Close navigation footer`} - accessibilityHint={t`Closes bottom navigation bar`}> + accessibilityLabel={_(msg`Close navigation footer`)} + accessibilityHint={_(msg`Closes bottom navigation bar`)}> <View style={styles.drawerContainer}> <DrawerContent /> </View> |