diff options
Diffstat (limited to 'src/view')
89 files changed, 2089 insertions, 3891 deletions
diff --git a/src/view/com/auth/HomeLoggedOutCTA.tsx b/src/view/com/auth/HomeLoggedOutCTA.tsx index f796d8bae..a5b5bf7ba 100644 --- a/src/view/com/auth/HomeLoggedOutCTA.tsx +++ b/src/view/com/auth/HomeLoggedOutCTA.tsx @@ -52,7 +52,9 @@ export function HomeLoggedOutCTA() { onPress={showCreateAccount} accessibilityRole="button" accessibilityLabel={_(msg`Create new account`)} - accessibilityHint="Opens flow to create a new Bluesky account"> + accessibilityHint={_( + msg`Opens flow to create a new Bluesky account`, + )}> <Text style={[ s.white, @@ -68,7 +70,9 @@ export function HomeLoggedOutCTA() { onPress={showSignIn} accessibilityRole="button" accessibilityLabel={_(msg`Sign in`)} - accessibilityHint="Opens flow to sign into your existing Bluesky account"> + accessibilityHint={_( + msg`Opens flow to sign into your existing Bluesky account`, + )}> <Text style={[ pal.text, diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 134ae11f1..f3d783476 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -66,7 +66,9 @@ export const SplashScreen = ({ onPress={onPressCreateAccount} accessibilityRole="button" accessibilityLabel={_(msg`Create new account`)} - accessibilityHint="Opens flow to create a new Bluesky account"> + accessibilityHint={_( + msg`Opens flow to create a new Bluesky account`, + )}> <Text style={[s.white, styles.btnLabel]}> <Trans>Create a new account</Trans> </Text> @@ -77,7 +79,9 @@ export const SplashScreen = ({ onPress={onPressSignin} accessibilityRole="button" accessibilityLabel={_(msg`Sign in`)} - accessibilityHint="Opens flow to sign into your existing Bluesky account"> + accessibilityHint={_( + msg`Opens flow to sign into your existing Bluesky account`, + )}> <Text style={[pal.text, styles.btnLabel]}> <Trans>Sign In</Trans> </Text> diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx index 2c7d60818..803e2ad32 100644 --- a/src/view/com/auth/create/Policies.tsx +++ b/src/view/com/auth/create/Policies.tsx @@ -9,6 +9,8 @@ import {TextLink} from '../../util/Link' 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' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -20,6 +22,7 @@ export const Policies = ({ needsGuardian: boolean }) => { const pal = usePalette('default') + const {_} = useLingui() if (!serviceDescription) { return <View /> } @@ -40,7 +43,9 @@ export const Policies = ({ /> </View> <Text style={[pal.textLight, s.pl5, s.flex1]}> - This service has not provided terms of service or a privacy policy. + <Trans> + This service has not provided terms of service or a privacy policy. + </Trans> </Text> </View> ) @@ -51,7 +56,7 @@ export const Policies = ({ <TextLink key="tos" href={tos} - text="Terms of Service" + text={_(msg`Terms of Service`)} style={[pal.link, s.underline]} />, ) @@ -61,7 +66,7 @@ export const Policies = ({ <TextLink key="pp" href={pp} - text="Privacy Policy" + text={_(msg`Privacy Policy`)} style={[pal.link, s.underline]} />, ) @@ -79,12 +84,14 @@ export const Policies = ({ return ( <View style={styles.policies}> <Text style={pal.textLight}> - By creating an account you agree to the {els}. + <Trans>By creating an account you agree to the {els}.</Trans> </Text> {needsGuardian && ( <Text style={[pal.textLight, s.bold]}> - If you are not yet an adult according to the laws of your country, - your parent or legal guardian must read these Terms on your behalf. + <Trans> + If you are not yet an adult according to the laws of your country, + your parent or legal guardian must read these Terms on your behalf. + </Trans> </Text> )} </View> 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/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx index 32cd8315d..d3b075fdb 100644 --- a/src/view/com/auth/login/ChooseAccountForm.tsx +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -45,7 +45,11 @@ function AccountItem({ accessibilityHint={_(msg`Double tap to sign in`)}> <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> <View style={s.p10}> - <UserAvatar avatar={profile?.avatar} size={30} /> + <UserAvatar + avatar={profile?.avatar} + size={30} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + /> </View> <Text style={styles.accountText}> <Text type="lg-bold" style={pal.text}> diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx index fdba9f203..3202d69c5 100644 --- a/src/view/com/auth/login/LoginForm.tsx +++ b/src/view/com/auth/login/LoginForm.tsx @@ -207,7 +207,7 @@ export const LoginForm = ({ testID="loginPasswordInput" ref={passwordInputRef} style={[pal.text, styles.textInput]} - placeholder="Password" + placeholder={_(msg`Password`)} placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 5f81a4d65..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' @@ -11,14 +11,15 @@ import {Text} from 'view/com/util/text/Text' import Animated, {FadeInRight} from 'react-native-reanimated' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' -import {Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {logger} from '#/logger' type Props = { profile: AppBskyActorDefs.ProfileViewBasic - moderation: ProfileModeration + moderation: ModerationDecision onFollowStateChange: (props: { did: string following: boolean @@ -62,7 +63,7 @@ function ProfileCard({ moderation, }: { profile: Shadow<AppBskyActorDefs.ProfileViewBasic> - moderation: ProfileModeration + moderation: ModerationDecision onFollowStateChange: (props: { did: string following: boolean @@ -70,6 +71,7 @@ function ProfileCard({ }) { const {track} = useAnalytics() const pal = usePalette('default') + const {_} = useLingui() const [addingMoreSuggestions, setAddingMoreSuggestions] = React.useState(false) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( @@ -113,7 +115,7 @@ function ProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> </View> <View style={styles.layoutContent}> @@ -124,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}> @@ -136,7 +138,7 @@ function ProfileCard({ type={profile.viewer?.following ? 'default' : 'inverted'} labelStyle={styles.followButton} onPress={onToggleFollow} - label={profile.viewer?.following ? 'Unfollow' : 'Follow'} + label={profile.viewer?.following ? _(msg`Unfollow`) : _(msg`Follow`)} /> </View> {profile.description ? ( diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx index 5de1a7817..b8659d56c 100644 --- a/src/view/com/auth/onboarding/WelcomeMobile.tsx +++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx @@ -6,7 +6,8 @@ import {usePalette} from 'lib/hooks/usePalette' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Button} from 'view/com/util/forms/Button' import {ViewHeader} from 'view/com/util/ViewHeader' -import {Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' type Props = { next: () => void @@ -15,6 +16,7 @@ type Props = { export function WelcomeMobile({next, skip}: Props) { const pal = usePalette('default') + const {_} = useLingui() return ( <View style={[styles.container]} testID="welcomeOnboarding"> @@ -91,7 +93,7 @@ export function WelcomeMobile({next, skip}: Props) { <Button onPress={next} - label="Continue" + label={_(msg`Continue`)} testID="continueBtn" style={[styles.buttonContainer]} labelStyle={styles.buttonText} diff --git a/src/view/com/auth/server-input/index.tsx b/src/view/com/auth/server-input/index.tsx index 32b5a3141..b26ac1dcb 100644 --- a/src/view/com/auth/server-input/index.tsx +++ b/src/view/com/auth/server-input/index.tsx @@ -115,7 +115,7 @@ export function ServerInputDialog({ testID="customServerTextInput" value={customAddress} onChangeText={setCustomAddress} - label={_(msg`my-server.com`)} + label="my-server.com" accessibilityLabelledBy="address-input-label" autoCapitalize="none" keyboardType="url" diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 97f8e5194..ddb01a8fa 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' @@ -415,7 +415,11 @@ export const ComposePost = observer(function ComposePost({ styles.textInputLayout, isNative && styles.textInputLayoutMobile, ]}> - <UserAvatar avatar={currentProfile?.avatar} size={50} /> + <UserAvatar + avatar={currentProfile?.avatar} + size={50} + type={currentProfile?.associated?.labeler ? 'labeler' : 'user'} + /> <TextInput ref={textInput} richtext={richtext} @@ -443,7 +447,7 @@ export const ComposePost = observer(function ComposePost({ /> )} {quote ? ( - <View style={[s.mt5, isWeb && s.mb10]}> + <View style={[s.mt5, isWeb && s.mb10, {pointerEvents: 'none'}]}> <QuoteEmbed quote={quote} /> </View> ) : undefined} diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 39a1473a3..0c1b87d04 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,8 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { <UserAvatar avatar={replyTo.author.avatar} size={50} - moderation={replyTo.moderation?.avatar} + moderation={replyTo.moderation?.ui('avatar')} + type={replyTo.author.associated?.labeler ? 'labeler' : 'user'} /> <View style={styles.replyToPost}> <Text type="xl-medium" style={[pal.text]}> @@ -103,7 +104,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/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 632bb2634..16d1b6fb9 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -23,7 +23,11 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { accessibilityRole="button" accessibilityLabel={_(msg`Compose reply`)} accessibilityHint={_(msg`Opens composer`)}> - <UserAvatar avatar={profile?.avatar} size={38} /> + <UserAvatar + avatar={profile?.avatar} + size={38} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + /> <Text type="xl" style={[ 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/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index c400aa48d..9c8f8f916 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -78,7 +78,11 @@ export function Autocomplete({ accessibilityLabel={`Select ${item.handle}`} accessibilityHint=""> <View style={styles.avatarAndHandle}> - <UserAvatar avatar={item.avatar ?? null} size={24} /> + <UserAvatar + avatar={item.avatar ?? null} + size={24} + type={item.associated?.labeler ? 'labeler' : 'user'} + /> <Text type="md-medium" style={pal.text}> {displayName} </Text> diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 76058fed3..29b8f0bc6 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -175,7 +175,11 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( }} accessibilityRole="button"> <View style={styles.avatarAndDisplayName}> - <UserAvatar avatar={item.avatar ?? null} size={26} /> + <UserAvatar + avatar={item.avatar ?? null} + size={26} + type={item.associated?.labeler ? 'labeler' : 'user'} + /> <Text style={pal.text} numberOfLines={1}> {displayName} </Text> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index b23dfedc8..3a040b5d0 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -6,16 +6,17 @@ * */ import React from 'react' -import {createHitslop} from 'lib/constants' import { SafeAreaView, TouchableOpacity, StyleSheet, ViewStyle, } from 'react-native' -import {t} from '@lingui/macro' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from '#/lib/hooks/usePalette' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {createHitslop} from '#/lib/constants' type Props = { onRequestClose: () => void @@ -24,8 +25,7 @@ type Props = { const HIT_SLOP = createHitslop(16) const ImageDefaultHeader = ({onRequestClose}: Props) => { - const pal = usePalette('default') - + const {_} = useLingui() return ( <SafeAreaView style={styles.root}> <TouchableOpacity @@ -33,10 +33,10 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => { onPress={onRequestClose} hitSlop={HIT_SLOP} accessibilityRole="button" - accessibilityLabel={t`Close image`} - accessibilityHint={t`Closes viewer for header image`} + accessibilityLabel={_(msg`Close image`)} + accessibilityHint={_(msg`Closes viewer for header image`)} onAccessibilityEscape={onRequestClose}> - <FontAwesomeIcon icon="close" color={pal.colors.text} size={22} /> + <FontAwesomeIcon icon="close" color={'#fff'} size={22} /> </TouchableOpacity> </SafeAreaView> ) diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 2ee5b8d59..5bab643ca 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -78,9 +78,9 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) { try { await saveImageToMediaLibrary({uri}) - Toast.show('Saved to your camera roll.') + Toast.show(_(msg`Saved to your camera roll.`)) } catch (e: any) { - Toast.show(`Failed to save image: ${String(e)}`) + Toast.show(_(msg`Failed to save image: ${String(e)}`)) } }, [permissionResponse, requestPermission, _], 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/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index a43c30c29..f04bdb0e4 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -150,7 +150,7 @@ export function Inner({ accessibilityHint={_(msg`Exits handle change process`)} onAccessibilityEscape={onPressCancel}> <Text type="lg" style={pal.textLight}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </View> @@ -254,7 +254,7 @@ function ProvidedHandleForm({ <TextInput testID="setHandleInput" style={[pal.text, styles.textInput]} - placeholder="e.g. alice" + placeholder={_(msg`e.g. alice`)} placeholderTextColor={pal.colors.textLight} autoCapitalize="none" keyboardAppearance={theme.colorScheme} @@ -277,8 +277,8 @@ function ProvidedHandleForm({ <TouchableOpacity onPress={onToggleCustom} accessibilityRole="button" - accessibilityHint="Hosting provider" - accessibilityLabel={_(msg`Opens modal for using custom domain`)}> + accessibilityLabel={_(msg`Hosting provider`)} + accessibilityHint={_(msg`Opens modal for using custom domain`)}> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> <Trans>I have my own domain</Trans> </Text> @@ -324,8 +324,8 @@ function CustomHandleForm({ Clipboard.setString( isDNSForm ? `did=${currentAccount.did}` : currentAccount.did, ) - Toast.show('Copied to clipboard') - }, [currentAccount, isDNSForm]) + Toast.show(_(msg`Copied to clipboard`)) + }, [currentAccount, isDNSForm, _]) const onChangeHandle = React.useCallback( (v: string) => { setHandle(v) @@ -378,7 +378,7 @@ function CustomHandleForm({ <TextInput testID="setHandleInput" style={[pal.text, styles.textInput]} - placeholder="e.g. alice.com" + placeholder={_(msg`e.g. alice.com`)} placeholderTextColor={pal.colors.textLight} autoCapitalize="none" keyboardAppearance={theme.colorScheme} @@ -387,7 +387,7 @@ function CustomHandleForm({ editable={!isProcessing} accessibilityLabelledBy="customDomain" accessibilityLabel={_(msg`Custom domain`)} - accessibilityHint="Input your preferred hosting provider" + accessibilityHint={_(msg`Input your preferred hosting provider`)} /> </View> <View style={styles.spacer} /> @@ -395,18 +395,18 @@ function CustomHandleForm({ <View style={[styles.selectableBtns]}> <SelectableBtn selected={isDNSForm} - label="DNS Panel" + label={_(msg`DNS Panel`)} left onSelect={() => setDNSForm(true)} - accessibilityHint="Use the DNS panel" + accessibilityHint={_(msg`Use the DNS panel`)} style={s.flex1} /> <SelectableBtn selected={!isDNSForm} - label="No DNS Panel" + label={_(msg`No DNS Panel`)} right onSelect={() => setDNSForm(false)} - accessibilityHint="Use a file on your server" + accessibilityHint={_(msg`Use a file on your server`)} style={s.flex1} /> </View> @@ -418,7 +418,7 @@ function CustomHandleForm({ </Text> <View style={[styles.dnsTable, pal.btn]}> <Text type="md-medium" style={[styles.dnsLabel, pal.text]}> - Host: + <Trans>Host:</Trans> </Text> <View style={[styles.dnsValue]}> <Text type="mono" style={[styles.monoText, pal.text]}> @@ -426,7 +426,7 @@ function CustomHandleForm({ </Text> </View> <Text type="md-medium" style={[styles.dnsLabel, pal.text]}> - Type: + <Trans>Type:</Trans> </Text> <View style={[styles.dnsValue]}> <Text type="mono" style={[styles.monoText, pal.text]}> @@ -434,7 +434,7 @@ function CustomHandleForm({ </Text> </View> <Text type="md-medium" style={[styles.dnsLabel, pal.text]}> - Value: + <Trans>Value:</Trans> </Text> <View style={[styles.dnsValue]}> <Text type="mono" style={[styles.monoText, pal.text]}> @@ -443,7 +443,7 @@ function CustomHandleForm({ </View> </View> <Text type="md" style={[pal.text, s.pt20, s.pl5]}> - This should create a domain record at:{' '} + <Trans>This should create a domain record at:</Trans> </Text> <Text type="mono" style={[styles.monoText, pal.text, s.pt5, s.pl5]}> _atproto.{handle} @@ -463,7 +463,7 @@ function CustomHandleForm({ </View> <View style={styles.spacer} /> <Text type="md" style={[pal.text, s.pb5, s.pl5]}> - That contains the following: + <Trans>That contains the following:</Trans> </Text> <View style={[styles.valueContainer, pal.btn]}> <View style={[styles.dnsValue]}> @@ -478,7 +478,9 @@ function CustomHandleForm({ <View style={styles.spacer} /> <Button type="default" style={[s.p20, s.mb10]} onPress={onPressCopy}> <Text type="xl" style={[pal.link, s.textCenter]}> - Copy {isDNSForm ? 'Domain Value' : 'File Contents'} + <Trans> + Copy {isDNSForm ? _(msg`Domain Value`) : _(msg`File Contents`)} + </Trans> </Text> </Button> {canSave === true && ( @@ -504,8 +506,8 @@ function CustomHandleForm({ ) : ( <Text type="xl-medium" style={[s.white, s.textCenter]}> {canSave - ? `Update to ${handle}` - : `Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`} + ? _(msg`Update to ${handle}`) + : _(msg`Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`)} </Text> )} </Button> @@ -513,9 +515,9 @@ function CustomHandleForm({ <TouchableOpacity onPress={onToggleCustom} accessibilityLabel={_(msg`Use default provider`)} - accessibilityHint="Use bsky.social as hosting provider"> + accessibilityHint={_(msg`Use bsky.social as hosting provider`)}> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> - Nevermind, create a handle for me + <Trans>Nevermind, create a handle for me</Trans> </Text> </TouchableOpacity> </> diff --git a/src/view/com/modals/ChangePassword.tsx b/src/view/com/modals/ChangePassword.tsx index d8add9794..4badc88aa 100644 --- a/src/view/com/modals/ChangePassword.tsx +++ b/src/view/com/modals/ChangePassword.tsx @@ -137,7 +137,9 @@ export function Component() { <View> <View style={styles.titleSection}> <Text type="title-lg" style={[pal.text, styles.title]}> - {stage !== Stages.Done ? 'Change Password' : 'Password Changed'} + {stage !== Stages.Done + ? _(msg`Change Password`) + : _(msg`Password Changed`)} </Text> </View> @@ -180,7 +182,7 @@ export function Component() { <TextInput testID="codeInput" style={[pal.text, styles.textInput]} - placeholder="Reset code" + placeholder={_(msg`Reset code`)} placeholderTextColor={pal.colors.textLight} value={resetCode} onChangeText={setResetCode} @@ -207,7 +209,7 @@ export function Component() { <TextInput testID="codeInput" style={[pal.text, styles.textInput]} - placeholder="New password" + placeholder={_(msg`New password`)} placeholderTextColor={pal.colors.textLight} onChangeText={setNewPassword} secureTextEntry 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/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 40d78cfe0..2301e7a66 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -173,7 +173,7 @@ export function Component({}: {}) { </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} - placeholder="Confirmation code" + placeholder={_(msg`Confirmation code`)} placeholderTextColor={pal.textLight.color} keyboardAppearance={theme.colorScheme} value={confirmCode} @@ -192,7 +192,7 @@ export function Component({}: {}) { </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text]} - placeholder="Password" + placeholder={_(msg`Password`)} placeholderTextColor={pal.textLight.color} keyboardAppearance={theme.colorScheme} secureTextEntry @@ -228,7 +228,7 @@ export function Component({}: {}) { onPress={onCancel} accessibilityRole="button" accessibilityLabel={_(msg`Cancel account deletion`)} - accessibilityHint="Exits account deletion process" + accessibilityHint={_(msg`Exits account deletion process`)} onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> <Trans context="action">Cancel</Trans> diff --git a/src/view/com/modals/InAppBrowserConsent.tsx b/src/view/com/modals/InAppBrowserConsent.tsx index 86bb46ca8..3fa515934 100644 --- a/src/view/com/modals/InAppBrowserConsent.tsx +++ b/src/view/com/modals/InAppBrowserConsent.tsx @@ -77,7 +77,7 @@ export function Component({href}: {href: string}) { }} accessibilityLabel={_(msg`Cancel`)} accessibilityHint="" - label="Cancel" + label={_(msg`Cancel`)} labelContainerStyle={{justifyContent: 'center', padding: 8}} labelStyle={[s.f18]} /> diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx index 81fdc7285..b5ff6700d 100644 --- a/src/view/com/modals/LinkWarning.tsx +++ b/src/view/com/modals/LinkWarning.tsx @@ -73,8 +73,8 @@ export function Component({text, href}: {text: string; href: string}) { type="primary" onPress={onPressVisit} accessibilityLabel={_(msg`Visit Site`)} - accessibilityHint="" - label="Visit Site" + accessibilityHint={_(msg`Opens the linked website`)} + label={_(msg`Visit Site`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -85,8 +85,8 @@ export function Component({text, href}: {text: string; href: string}) { closeModal() }} accessibilityLabel={_(msg`Cancel`)} - accessibilityHint="" - label="Cancel" + accessibilityHint={_(msg`Cancels opening the linked website`)} + label={_(msg`Cancel`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> diff --git a/src/view/com/modals/ListAddRemoveUsers.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx index 27c33f806..4715348dd 100644 --- a/src/view/com/modals/ListAddRemoveUsers.tsx +++ b/src/view/com/modals/ListAddRemoveUsers.tsx @@ -231,7 +231,11 @@ function UserResult({ width: 54, paddingLeft: 4, }}> - <UserAvatar size={40} avatar={profile.avatar} /> + <UserAvatar + size={40} + avatar={profile.avatar} + type={profile.associated?.labeler ? 'labeler' : 'user'} + /> </View> <View style={{ 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 f890d50dc..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]}> - Okay - </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/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index c034c4b52..0658805bd 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -45,7 +45,11 @@ function SwitchAccountCard({account}: {account: SessionAccount}) { const contents = ( <View style={[pal.view, styles.linkCard]}> <View style={styles.avi}> - <UserAvatar size={40} avatar={profile?.avatar} /> + <UserAvatar + size={40} + avatar={profile?.avatar} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + /> </View> <View style={[s.flex1]}> <Text type="md-bold" style={pal.text} numberOfLines={1}> diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index 8452f2513..8a61b1a70 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -180,7 +180,7 @@ function ListItem({ }, ]}> <View style={styles.listItemAvi}> - <UserAvatar size={40} avatar={list.avatar} /> + <UserAvatar size={40} avatar={list.avatar} type="list" /> </View> <View style={styles.listItemContent}> <Text diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx index 30a57afc5..d3086d383 100644 --- a/src/view/com/modals/VerifyEmail.tsx +++ b/src/view/com/modals/VerifyEmail.tsx @@ -149,7 +149,7 @@ export function Component({showReminder}: {showReminder?: boolean}) { onPress={onEmailIncorrect} style={styles.changeEmailLink}> <Text type="lg" style={pal.link}> - Change + <Trans>Change</Trans> </Text> </Pressable> </> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 6f094a1fd..98a2494ed 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -100,7 +100,7 @@ export function Component({ onPress={doSetAs(AspectRatio.Wide)} accessibilityRole="button" accessibilityLabel={_(msg`Wide`)} - accessibilityHint="Sets image aspect ratio to wide"> + accessibilityHint={_(msg`Sets image aspect ratio to wide`)}> <RectWideIcon size={24} style={as === AspectRatio.Wide ? s.blue3 : pal.text} @@ -110,7 +110,7 @@ export function Component({ onPress={doSetAs(AspectRatio.Tall)} accessibilityRole="button" accessibilityLabel={_(msg`Tall`)} - accessibilityHint="Sets image aspect ratio to tall"> + accessibilityHint={_(msg`Sets image aspect ratio to tall`)}> <RectTallIcon size={24} style={as === AspectRatio.Tall ? s.blue3 : pal.text} @@ -120,7 +120,7 @@ export function Component({ onPress={doSetAs(AspectRatio.Square)} accessibilityRole="button" accessibilityLabel={_(msg`Square`)} - accessibilityHint="Sets image aspect ratio to square"> + accessibilityHint={_(msg`Sets image aspect ratio to square`)}> <SquareIcon size={24} style={as === AspectRatio.Square ? s.blue3 : pal.text} @@ -132,9 +132,9 @@ export function Component({ onPress={onPressCancel} accessibilityRole="button" accessibilityLabel={_(msg`Cancel image crop`)} - accessibilityHint="Exits image cropping process"> + accessibilityHint={_(msg`Exits image cropping process`)}> <Text type="xl" style={pal.link}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> <View style={s.flex1} /> @@ -142,7 +142,7 @@ export function Component({ onPress={onPressDone} accessibilityRole="button" accessibilityLabel={_(msg`Save image crop`)} - accessibilityHint="Saves image crop settings"> + accessibilityHint={_(msg`Saves image crop settings`)}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx deleted file mode 100644 index 2bc86f75e..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="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="Enter a reason for reporting this post." - placeholder="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 abbad9b40..000000000 --- a/src/view/com/modals/report/Modal.tsx +++ /dev/null @@ -1,223 +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]: 'Feed', - [CollectionId.Profile]: 'Profile', - [CollectionId.List]: 'List', - [CollectionId.Post]: 'Post', -} - -type ReportComponentProps = - | { - uri: string - cid: string - } - | { - did: string - } - -export function Component(content: ReportComponentProps) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - 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("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 'Account' - // Generic fallback for any collection being reported - return CollectionNames[atUri.collection as CollectionId] || 'Content' -} - -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="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 23b49b664..000000000 --- a/src/view/com/modals/report/ReasonOptions.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import {View} from 'react-native' -import React, {useMemo} from 'react' -import {AtUri, ComAtprotoModerationDefs} from '@atproto/api' - -import {Text} from '../../util/text/Text' -import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette' -import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup' -import {CollectionId} from './types' - -type ReasonMap = Record<string, {title: string; description: string}> -const CommonReasons = { - [ComAtprotoModerationDefs.REASONRUDE]: { - title: 'Anti-Social Behavior', - description: 'Harassment, trolling, or intolerance', - }, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: 'Illegal and Urgent', - description: 'Glaring violations of law or terms of service', - }, - [ComAtprotoModerationDefs.REASONOTHER]: { - title: 'Other', - description: 'An issue not included in these options', - }, -} -const CollectionToReasonsMap: Record<string, ReasonMap> = { - [CollectionId.Post]: { - [ComAtprotoModerationDefs.REASONSPAM]: { - title: 'Spam', - description: 'Excessive mentions or replies', - }, - [ComAtprotoModerationDefs.REASONSEXUAL]: { - title: 'Unwanted Sexual Content', - description: 'Nudity or pornography not labeled as such', - }, - __copyright__: { - title: 'Copyright Violation', - description: 'Contains copyrighted material', - }, - ...CommonReasons, - }, - [CollectionId.List]: { - ...CommonReasons, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: 'Name or Description Violates Community Standards', - description: 'Terms used violate community standards', - }, - }, -} -const AccountReportReasons = { - [ComAtprotoModerationDefs.REASONMISLEADING]: { - title: 'Misleading Account', - description: 'Impersonation or false claims about identity or affiliation', - }, - [ComAtprotoModerationDefs.REASONSPAM]: { - title: 'Frequently Posts Unwanted Content', - description: 'Spam; excessive mentions or replies', - }, - [ComAtprotoModerationDefs.REASONVIOLATION]: { - title: 'Name or Description Violates Community Standards', - description: 'Terms used violate community standards', - }, -} - -const Option = ({ - pal, - title, - description, -}: { - pal: UsePaletteValue - description: string - title: string -}) => { - return ( - <View> - <Text style={pal.text} type="md-bold"> - {title} - </Text> - <Text style={pal.textLight}>{description}</Text> - </View> - ) -} - -// This is mostly just content copy without almost any logic -// so this may grow over time and it makes sense to split it up into its own file -// to keep it separate from the actual reporting modal logic -const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) => - useMemo(() => { - let items: ReasonMap = {...CommonReasons} - // If no atUri is passed, that means the reporting collection is account - if (!atUri) { - items = {...AccountReportReasons} - } - - if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) { - items = {...CollectionToReasonsMap[atUri.collection]} - } - - return Object.entries(items).map(([key, {title, description}]) => ({ - key, - label: <Option pal={pal} title={title} description={description} />, - })) - }, [pal, atUri]) - -export const ReportReasonOptions = ({ - atUri, - selectedIssue, - onSelectIssue, -}: { - atUri: AtUri | null - selectedIssue?: string - onSelectIssue: (key: string) => void -}) => { - const pal = usePalette('default') - const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri) - return ( - <RadioGroup - items={ITEMS} - onSelect={onSelectIssue} - testID="reportReasonRadios" - initialSelection={selectedIssue} - /> - ) -} diff --git a/src/view/com/modals/report/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..78b1677c3 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -11,9 +11,10 @@ import { AppBskyFeedDefs, AppBskyFeedPost, ModerationOpts, - ProfileModeration, + ModerationDecision, moderateProfile, AppBskyEmbedRecordWithMedia, + AppBskyActorDefs, } from '@atproto/api' import {AtUri} from '@atproto/api' import { @@ -54,7 +55,8 @@ interface Author { handle: string displayName?: string avatar?: string - moderation: ProfileModeration + moderation: ModerationDecision + associated?: AppBskyActorDefs.ProfileAssociated } let FeedItem = ({ @@ -100,6 +102,7 @@ let FeedItem = ({ displayName: item.notification.author.displayName, avatar: item.notification.author.avatar, moderation: moderateProfile(item.notification.author, moderationOpts), + associated: item.notification.author.associated, }, ...(item.additional?.map(({author}) => { return { @@ -109,6 +112,7 @@ let FeedItem = ({ displayName: author.displayName, avatar: author.avatar, moderation: moderateProfile(author, moderationOpts), + associated: author.associated, } }) || []), ] @@ -336,7 +340,8 @@ 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')} + type={authors[0].associated?.labeler ? 'labeler' : 'user'} /> </View> ) @@ -354,7 +359,8 @@ function CondensedAuthorsList({ <UserAvatar size={35} avatar={author.avatar} - moderation={author.moderation.avatar} + moderation={author.moderation.ui('avatar')} + type={author.associated?.labeler ? 'labeler' : 'user'} /> </View> ))} @@ -412,7 +418,8 @@ function ExpandedAuthorsList({ <UserAvatar size={35} avatar={author.avatar} - moderation={author.moderation.avatar} + moderation={author.moderation.ui('avatar')} + type={author.associated?.labeler ? 'labeler' : 'user'} /> </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..8042e7bd5 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,25 +1,14 @@ import React, {useEffect, useRef} from 'react' -import { - ActivityIndicator, - Pressable, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' +import {StyleSheet, useWindowDimensions, View} from 'react-native' import {AppBskyFeedDefs} from '@atproto/api' -import {CenteredView} from '../util/Views' -import {LoadingScreen} from '../util/LoadingScreen' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + import {List, ListMethods} from '../util/List' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' import {PostThreadItem} from './PostThreadItem' import {ComposePrompt} from '../composer/Prompt' import {ViewHeader} from '../util/ViewHeader' -import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' -import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import { @@ -30,21 +19,18 @@ import { usePostThreadQuery, sortThread, } from '#/state/queries/post-thread' -import {useNavigation} from '@react-navigation/native' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' import {sanitizeDisplayName} from 'lib/strings/display-names' -import {cleanError} from '#/lib/strings/errors' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' import { - UsePreferencesQueryResponse, useModerationOpts, usePreferencesQuery, } from '#/state/queries/preferences' import {useSession} from '#/state/session' import {isAndroid, isNative, isWeb} from '#/platform/detection' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' +import {cleanError} from 'lib/strings/errors' // FlatList maintainVisibleContentPosition breaks if too many items // are prepended. This seems to be an optimal number based on *shrug*. @@ -58,9 +44,7 @@ const MAINTAIN_VISIBLE_CONTENT_POSITION = { const TOP_COMPONENT = {_reactKey: '__top_component__'} const REPLY_PROMPT = {_reactKey: '__reply__'} -const CHILD_SPINNER = {_reactKey: '__child_spinner__'} const LOAD_MORE = {_reactKey: '__load_more__'} -const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'} type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound type RowItem = @@ -68,9 +52,7 @@ type RowItem = // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. | typeof TOP_COMPONENT | typeof REPLY_PROMPT - | typeof CHILD_SPINNER | typeof LOAD_MORE - | typeof BOTTOM_COMPONENT type ThreadSkeletonParts = { parents: YieldedItem[] @@ -78,6 +60,10 @@ type ThreadSkeletonParts = { replies: YieldedItem[] } +const keyExtractor = (item: RowItem) => { + return item._reactKey +} + export function PostThread({ uri, onCanReply, @@ -85,17 +71,30 @@ export function PostThread({ }: { uri: string | undefined onCanReply: (canReply: boolean) => void - onPressReply: () => void + onPressReply: () => unknown }) { + const {hasSession} = useSession() + const {_} = useLingui() + const pal = usePalette('default') + const {isMobile, isTabletOrMobile} = useWebMediaQueries() + const initialNumToRender = useInitialNumToRender() + const {height: windowHeight} = useWindowDimensions() + + const {data: preferences} = usePreferencesQuery() const { - isLoading, - isError, - error, + isFetching, + isError: isThreadError, + error: threadError, refetch, data: thread, } = usePostThreadQuery(uri) - const {data: preferences} = usePreferencesQuery() + const treeView = React.useMemo( + () => + !!preferences?.threadViewPrefs?.lab_treeViewEnabled && + hasBranchingReplies(thread), + [preferences?.threadViewPrefs, thread], + ) const rootPost = thread?.type === 'post' ? thread.post : undefined const rootPostRecord = thread?.type === 'post' ? thread.record : undefined @@ -105,14 +104,22 @@ export function PostThread({ rootPost && moderationOpts ? 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]) + // Values used for proper rendering of parents + const ref = useRef<ListMethods>(null) + const highlightedPostRef = useRef<View | null>(null) + const [maxParents, setMaxParents] = React.useState( + isWeb ? Infinity : PARENTS_CHUNK_SIZE, + ) + const [maxReplies, setMaxReplies] = React.useState(50) + useSetTitle( rootPost && !isNoPwi ? `${sanitizeDisplayName( @@ -120,62 +127,6 @@ export function PostThread({ )}: "${rootPostRecord!.text}"` : '', ) - useEffect(() => { - if (rootPost) { - onCanReply(!rootPost.viewer?.replyDisabled) - } - }, [rootPost, onCanReply]) - - if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) { - return ( - <PostThreadError - error={error} - notFound={AppBskyFeedDefs.isNotFoundPost(thread)} - onRefresh={refetch} - /> - ) - } - if (AppBskyFeedDefs.isBlockedPost(thread)) { - return <PostThreadBlocked /> - } - if (!thread || isLoading || !preferences) { - return <LoadingScreen /> - } - return ( - <PostThreadLoaded - thread={thread} - threadViewPrefs={preferences.threadViewPrefs} - onRefresh={refetch} - onPressReply={onPressReply} - /> - ) -} - -function PostThreadLoaded({ - thread, - threadViewPrefs, - onRefresh, - onPressReply, -}: { - thread: ThreadNode - threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs'] - onRefresh: () => void - onPressReply: () => void -}) { - const {hasSession} = useSession() - const {_} = useLingui() - const pal = usePalette('default') - const {isMobile, isTabletOrMobile} = useWebMediaQueries() - const ref = useRef<ListMethods>(null) - const highlightedPostRef = useRef<View | null>(null) - const [maxParents, setMaxParents] = React.useState( - isWeb ? Infinity : PARENTS_CHUNK_SIZE, - ) - const [maxReplies, setMaxReplies] = React.useState(100) - const treeView = React.useMemo( - () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread), - [threadViewPrefs, thread], - ) // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. // This ensures that the first render contains no parents--even if they are already available in the cache. @@ -183,18 +134,56 @@ function PostThreadLoaded({ // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. const [deferParents, setDeferParents] = React.useState(isNative) - const skeleton = React.useMemo( - () => - createThreadSkeleton( - sortThread(thread, threadViewPrefs), - hasSession, - treeView, - ), - [thread, threadViewPrefs, hasSession, treeView], - ) + const skeleton = React.useMemo(() => { + const threadViewPrefs = preferences?.threadViewPrefs + if (!threadViewPrefs || !thread) return null + + return createThreadSkeleton( + sortThread(thread, threadViewPrefs), + hasSession, + treeView, + ) + }, [thread, preferences?.threadViewPrefs, hasSession, treeView]) + + const error = React.useMemo(() => { + if (AppBskyFeedDefs.isNotFoundPost(thread)) { + return { + title: _(msg`Post not found`), + message: _(msg`The post may have been deleted.`), + } + } else if (skeleton?.highlightedPost.type === 'blocked') { + return { + title: _(msg`Post hidden`), + message: _( + msg`You have blocked the author or you have been blocked by the author.`, + ), + } + } else if (threadError?.message.startsWith('Post not found')) { + return { + title: _(msg`Post not found`), + message: _(msg`The post may have been deleted.`), + } + } else if (isThreadError) { + return { + message: threadError ? cleanError(threadError) : undefined, + } + } + + return null + }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) + + useEffect(() => { + if (error) { + onCanReply(false) + } else if (rootPost) { + onCanReply(!rootPost.viewer?.replyDisabled) + } + }, [rootPost, onCanReply, error]) // construct content const posts = React.useMemo(() => { + if (!skeleton) return [] + const {parents, highlightedPost, replies} = skeleton let arr: RowItem[] = [] if (highlightedPost.type === 'post') { @@ -230,17 +219,11 @@ function PostThreadLoaded({ if (!highlightedPost.post.viewer?.replyDisabled) { arr.push(REPLY_PROMPT) } - if (highlightedPost.ctx.isChildLoading) { - arr.push(CHILD_SPINNER) - } else { - for (let i = 0; i < replies.length; i++) { - arr.push(replies[i]) - if (i === maxReplies) { - arr.push(LOAD_MORE) - break - } + for (let i = 0; i < replies.length; i++) { + arr.push(replies[i]) + if (i === maxReplies) { + break } - arr.push(BOTTOM_COMPONENT) } } return arr @@ -255,7 +238,7 @@ function PostThreadLoaded({ return } // wait for loading to finish - if (thread.type === 'post' && !!thread.parent) { + if (thread?.type === 'post' && !!thread.parent) { function onMeasure(pageY: number) { ref.current?.scrollToOffset({ animated: false, @@ -279,10 +262,10 @@ function PostThreadLoaded({ // To work around this, we prepend rows after scroll bumps against the top and rests. const needsBumpMaxParents = React.useRef(false) const onStartReached = React.useCallback(() => { - if (maxParents < skeleton.parents.length) { + if (skeleton?.parents && maxParents < skeleton.parents.length) { needsBumpMaxParents.current = true } - }, [maxParents, skeleton.parents.length]) + }, [maxParents, skeleton?.parents]) const bumpMaxParentsIfNeeded = React.useCallback(() => { if (!isNative) { return @@ -295,6 +278,11 @@ function PostThreadLoaded({ const onMomentumScrollEnd = bumpMaxParentsIfNeeded const onScrollToTop = bumpMaxParentsIfNeeded + const onEndReached = React.useCallback(() => { + if (isFetching || posts.length < maxReplies) return + setMaxReplies(prev => prev + 50) + }, [isFetching, maxReplies, posts.length]) + const renderItem = React.useCallback( ({item, index}: {item: RowItem; index: number}) => { if (item === TOP_COMPONENT) { @@ -325,46 +313,6 @@ function PostThreadLoaded({ </Text> </View> ) - } else if (item === LOAD_MORE) { - return ( - <Pressable - onPress={() => setMaxReplies(n => n + 50)} - style={[pal.border, pal.view, styles.itemContainer]} - accessibilityLabel={_(msg`Load more posts`)} - accessibilityHint=""> - <View - style={[ - pal.viewLight, - {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6}, - ]}> - <Text type="lg-medium" style={pal.text}> - <Trans>Load more posts</Trans> - </Text> - </View> - </Pressable> - ) - } else if (item === BOTTOM_COMPONENT) { - // HACK - // due to some complexities with how flatlist works, this is the easiest way - // I could find to get a border positioned directly under the last item - // -prf - return ( - <View - // @ts-ignore web-only - style={{ - // Leave enough space below that the scroll doesn't jump - height: isNative ? 600 : '100vh', - borderTopWidth: 1, - borderColor: pal.colors.border, - }} - /> - ) - } else if (item === CHILD_SPINNER) { - return ( - <View style={[pal.border, styles.childSpinner]}> - <ActivityIndicator /> - </View> - ) } else if (isThreadPost(item)) { const prev = isThreadPost(posts[index - 1]) ? (posts[index - 1] as ThreadPost) @@ -373,7 +321,9 @@ function PostThreadLoaded({ ? (posts[index - 1] as ThreadPost) : undefined const hasUnrevealedParents = - index === 0 && maxParents < skeleton.parents.length + index === 0 && + skeleton?.parents && + maxParents < skeleton.parents.length return ( <View ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} @@ -390,9 +340,9 @@ function PostThreadLoaded({ showChildReplyLine={item.ctx.showChildReplyLine} showParentReplyLine={item.ctx.showParentReplyLine} hasPrecedingItem={ - !!prev?.ctx.showChildReplyLine || hasUnrevealedParents + !!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents } - onPostReply={onRefresh} + onPostReply={refetch} /> </View> ) @@ -402,142 +352,62 @@ function PostThreadLoaded({ [ hasSession, isTabletOrMobile, + _, isMobile, onPressReply, pal.border, pal.viewLight, pal.textLight, - pal.view, - pal.text, - pal.colors.border, posts, - onRefresh, + skeleton?.parents, + maxParents, deferParents, treeView, - skeleton.parents.length, - maxParents, - _, + refetch, ], ) return ( - <List - ref={ref} - data={posts} - keyExtractor={item => item._reactKey} - renderItem={renderItem} - onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} - onStartReached={onStartReached} - onMomentumScrollEnd={onMomentumScrollEnd} - onScrollToTop={onScrollToTop} - maintainVisibleContentPosition={ - isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined - } - style={s.hContentRegion} - // @ts-ignore our .web version only -prf - desktopFixedHeight - removeClippedSubviews={isAndroid ? false : undefined} - windowSize={11} - /> - ) -} - -function PostThreadBlocked() { - const {_} = useLingui() - const pal = usePalette('default') - const navigation = useNavigation<NavigationProp>() - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb5]}> - <Trans>Post hidden</Trans> - </Text> - <Text type="md" style={[pal.text, s.mb10]}> - <Trans> - You have blocked the author or you have been blocked by the author. - </Trans> - </Text> - <TouchableOpacity - onPress={onPressBack} - accessibilityRole="button" - accessibilityLabel={_(msg`Back`)} - accessibilityHint=""> - <Text type="2xl" style={pal.link}> - <FontAwesomeIcon - icon="angle-left" - style={[pal.link as FontAwesomeIconStyle, s.mr5]} - size={14} + <> + <ListMaybePlaceholder + isLoading={!preferences || !thread} + isError={!!error} + onRetry={refetch} + errorTitle={error?.title} + errorMessage={error?.message} + /> + {!error && thread && ( + <List + ref={ref} + data={posts} + renderItem={renderItem} + keyExtractor={keyExtractor} + onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} + onStartReached={onStartReached} + onEndReached={onEndReached} + onEndReachedThreshold={2} + onMomentumScrollEnd={onMomentumScrollEnd} + onScrollToTop={onScrollToTop} + maintainVisibleContentPosition={ + isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined + } + // @ts-ignore our .web version only -prf + desktopFixedHeight + removeClippedSubviews={isAndroid ? false : undefined} + ListFooterComponent={ + <ListFooter + isFetching={isFetching} + onRetry={refetch} + // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to + // work without causing weird jumps on web or glitches on native + height={windowHeight - 200} /> - <Trans context="action">Back</Trans> - </Text> - </TouchableOpacity> - </View> - </CenteredView> - ) -} - -function PostThreadError({ - onRefresh, - notFound, - error, -}: { - onRefresh: () => void - notFound: boolean - error: Error | null -}) { - const {_} = useLingui() - const pal = usePalette('default') - const navigation = useNavigation<NavigationProp>() - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - if (notFound) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb5]}> - <Trans>Post not found</Trans> - </Text> - <Text type="md" style={[pal.text, s.mb10]}> - <Trans>The post may have been deleted.</Trans> - </Text> - <TouchableOpacity - onPress={onPressBack} - accessibilityRole="button" - accessibilityLabel={_(msg`Back`)} - accessibilityHint=""> - <Text type="2xl" style={pal.link}> - <FontAwesomeIcon - icon="angle-left" - style={[pal.link as FontAwesomeIconStyle, s.mr5]} - size={14} - /> - <Trans>Back</Trans> - </Text> - </TouchableOpacity> - </View> - </CenteredView> - ) - } - return ( - <CenteredView> - <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} /> - </CenteredView> + } + initialNumToRender={initialNumToRender} + windowSize={11} + /> + )} + </> ) } @@ -557,7 +427,9 @@ function createThreadSkeleton( node: ThreadNode, hasSession: boolean, treeView: boolean, -): ThreadSkeletonParts { +): ThreadSkeletonParts | null { + if (!node) return null + return { parents: Array.from(flattenThreadParents(node, hasSession)), highlightedPost: node, @@ -614,7 +486,10 @@ function hasPwiOptOut(node: ThreadPost) { return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') } -function hasBranchingReplies(node: ThreadNode) { +function hasBranchingReplies(node?: ThreadNode) { + if (!node) { + return false + } if (node.type !== 'post') { return false } @@ -628,20 +503,9 @@ function hasBranchingReplies(node: ThreadNode) { } const styles = StyleSheet.create({ - notFoundContainer: { - margin: 10, - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 6, - }, itemContainer: { borderTopWidth: 1, paddingHorizontal: 18, paddingVertical: 18, }, - childSpinner: { - borderTopWidth: 1, - paddingTop: 40, - paddingBottom: 200, - }, }) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index cd746f9a9..6555bdf73 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') @@ -206,11 +205,7 @@ let PostThreadItemLoaded = ({ uri: post.uri, cid: post.cid, text: record.text, - author: { - handle: post.author.handle, - displayName: post.author.displayName, - avatar: post.author.avatar, - }, + author: post.author, embed: post.embed, moderation, }, @@ -256,7 +251,8 @@ let PostThreadItemLoaded = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} /> </View> <View style={styles.layoutContent}> @@ -271,35 +267,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}> - Muted - </Text> - </View> - )} <Link style={s.flex1} href={authorHref} title={authorTitle}> <Text type="md" style={[pal.textLight]} numberOfLines={1}> {sanitizeHandle(post.author.handle, '@')} @@ -312,15 +285,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 +312,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 +397,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 +448,8 @@ let PostThreadItemLoaded = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} /> {showChildReplyLine && ( @@ -508,19 +475,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 +511,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} @@ -578,7 +538,7 @@ let PostThreadItemLoaded = ({ title={itemTitle} noFeedback> <Text type="sm-medium" style={pal.textLight}> - More + <Trans>More</Trans> </Text> <FontAwesomeIcon icon="angle-right" diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 7e53eb271..47e46eb0c 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> }) { @@ -117,11 +118,7 @@ function PostInner({ uri: post.uri, cid: post.cid, text: record.text, - author: { - handle: post.author.handle, - displayName: post.author.displayName, - avatar: post.author.avatar, - }, + author: post.author, embed: post.embed, moderation, }, @@ -142,12 +139,14 @@ function PostInner({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} /> </View> <View style={styles.layoutContent}> <PostMeta author={post.author} + moderation={moderation} authorHasWarning={!!post.author.labels?.length} timestamp={post.indexedAt} postHref={itemHref} @@ -176,11 +175,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 +205,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..0fbcc4a13 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) { @@ -131,11 +126,7 @@ let FeedItemInner = ({ uri: post.uri, cid: post.cid, text: record.text || '', - author: { - handle: post.author.handle, - displayName: post.author.displayName, - avatar: post.author.avatar, - }, + author: post.author, embed: post.embed, moderation, }, @@ -148,7 +139,7 @@ let FeedItemInner = ({ borderColor: pal.colors.border, paddingBottom: isThreadLastChild || (!isThreadChild && !isThreadParent) - ? 6 + ? 8 : undefined, }, isThreadChild ? styles.outerSmallTop : undefined, @@ -229,6 +220,7 @@ let FeedItemInner = ({ numberOfLines={1} text={sanitizeDisplayName( reason.by.displayName || sanitizeHandle(reason.by.handle), + moderation.ui('displayName'), )} href={makeProfileLink(reason.by)} /> @@ -246,7 +238,8 @@ let FeedItemInner = ({ did={post.author.did} handle={post.author.handle} avatar={post.author.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} /> {isThreadParent && ( <View @@ -264,6 +257,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 +289,7 @@ let FeedItemInner = ({ </Text> </View> )} + <LabelsOnMyPost post={post} /> <PostContent moderation={moderation} richText={richText} @@ -306,9 +301,6 @@ let FeedItemInner = ({ record={record} richText={richText} onPressReply={onPressReply} - showAppealLabelItem={ - post.author.did === currentAccount?.did && isModeratedPost - } logContext="FeedItem" /> </View> @@ -324,7 +316,7 @@ let PostContent = ({ postEmbed, postAuthor, }: { - moderation: PostModeration + moderation: ModerationDecision richText: RichTextAPI postEmbed: AppBskyFeedDefs.PostView['embed'] postAuthor: AppBskyFeedDefs.PostView['author'] @@ -342,10 +334,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 +359,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..235139fff 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,20 +43,19 @@ export function ProfileCard({ renderButton?: ( profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, ) => React.ReactNode + onPress?: () => void style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') const profile = useProfileShadow(profileUnshadowed) const moderationOpts = useModerationOpts() + const isLabeler = profile?.associated?.labeler if (!moderationOpts) { 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 +71,7 @@ export function ProfileCard({ ]} href={makeProfileLink(profile)} title={profile.handle} + onBeforePress={onPress} asAnchor anchorNoUnderline> <View style={styles.layout}> @@ -80,7 +79,8 @@ export function ProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} + type={isLabeler ? 'labeler' : 'user'} /> </View> <View style={styles.layoutContent}> @@ -91,7 +91,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}> @@ -103,7 +103,7 @@ export function ProfileCard({ /> {!!profile.viewer?.followedBy && <View style={s.flexRow} />} </View> - {renderButton ? ( + {renderButton && !isLabeler ? ( <View style={styles.layoutButton}>{renderButton(profile)}</View> ) : undefined} </View> @@ -119,17 +119,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 +142,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 +199,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 +221,12 @@ 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')} + type={f.associated?.labeler ? 'labeler' : 'user'} + /> </View> </View> ))} @@ -212,11 +239,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 +263,7 @@ export function ProfileCardWithFollowBtn({ <FollowButton profile={profileShadow} logContext="ProfileCard" /> ) } + onPress={onPress} /> ) } diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 411ae6c17..b11a33f27 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -1,39 +1,66 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' -import {CenteredView} from '../util/Views' -import {LoadingScreen} from '../util/LoadingScreen' import {List} from '../util/List' -import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' import {useProfileFollowersQuery} from '#/state/queries/profile-followers' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {logger} from '#/logger' import {cleanError} from '#/lib/strings/errors' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from 'state/session' +import {View} from 'react-native' + +function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { + return <ProfileCardWithFollowBtn key={item.did} profile={item} /> +} + +function keyExtractor(item: ActorDefs.ProfileViewBasic) { + return item.did +} export function ProfileFollowers({name}: {name: string}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const {currentAccount} = useSession() + const [isPTRing, setIsPTRing] = React.useState(false) const { data: resolvedDid, + isLoading: isDidLoading, error: resolveError, - isFetching: isFetchingDid, } = useResolveDidQuery(name) const { data, + isLoading: isFollowersLoading, isFetching, - isFetched, isFetchingNextPage, hasNextPage, fetchNextPage, - isError, error, refetch, } = useProfileFollowersQuery(resolvedDid) + const isError = React.useMemo( + () => !!resolveError || !!error, + [resolveError, error], + ) + + const isMe = React.useMemo(() => { + return resolvedDid === currentAccount?.did + }, [resolvedDid, currentAccount?.did]) + const followers = React.useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.followers) } + return [] }, [data]) const onRefresh = React.useCallback(async () => { @@ -47,7 +74,7 @@ export function ProfileFollowers({name}: {name: string}) { }, [refetch, setIsPTRing]) const onEndReached = async () => { - if (isFetching || !hasNextPage || isError) return + if (isFetching || !hasNextPage || !!error) return try { await fetchNextPage() } catch (err) { @@ -55,57 +82,38 @@ export function ProfileFollowers({name}: {name: string}) { } } - const renderItem = React.useCallback( - ({item}: {item: ActorDefs.ProfileViewBasic}) => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ), - [], - ) - - if (isFetchingDid || !isFetched) { - return <LoadingScreen /> - } - - // error - // = - if (resolveError || isError) { - return ( - <CenteredView> - <ErrorMessage - message={cleanError(resolveError || error)} - onPressTryAgain={onRefresh} - /> - </CenteredView> - ) - } - - // loaded - // = return ( - <List - data={followers} - keyExtractor={item => item.did} - refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - <View style={styles.footer}> - {(isFetching || isFetchingNextPage) && <ActivityIndicator />} - </View> + <View style={{flex: 1}}> + <ListMaybePlaceholder + isLoading={isDidLoading || isFollowersLoading} + isEmpty={followers.length < 1} + isError={isError} + emptyType="results" + emptyMessage={ + isMe + ? _(msg`You do not have any followers.`) + : _(msg`This user doesn't have any followers.`) + } + errorMessage={cleanError(resolveError || error)} + onRetry={isError ? refetch : undefined} + /> + {followers.length > 0 && ( + <List + data={followers} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTRing} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={4} + ListHeaderComponent={<ListHeaderDesktop title={_(msg`Followers`)} />} + ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />} + // @ts-ignore our .web version only -prf + desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} + /> )} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> + </View> ) } - -const styles = StyleSheet.create({ - footer: { - height: 200, - paddingTop: 20, - }, -}) diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index bd4af1081..d99e2b840 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -1,39 +1,65 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' -import {CenteredView} from '../util/Views' -import {LoadingScreen} from '../util/LoadingScreen' import {List} from '../util/List' -import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {logger} from '#/logger' import {cleanError} from '#/lib/strings/errors' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {useSession} from 'state/session' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { + return <ProfileCardWithFollowBtn key={item.did} profile={item} /> +} + +function keyExtractor(item: ActorDefs.ProfileViewBasic) { + return item.did +} export function ProfileFollows({name}: {name: string}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const {currentAccount} = useSession() + const [isPTRing, setIsPTRing] = React.useState(false) const { data: resolvedDid, + isLoading: isDidLoading, error: resolveError, - isFetching: isFetchingDid, } = useResolveDidQuery(name) const { data, + isLoading: isFollowsLoading, isFetching, - isFetched, isFetchingNextPage, hasNextPage, fetchNextPage, - isError, error, refetch, } = useProfileFollowsQuery(resolvedDid) + const isError = React.useMemo( + () => !!resolveError || !!error, + [resolveError, error], + ) + + const isMe = React.useMemo(() => { + return resolvedDid === currentAccount?.did + }, [resolvedDid, currentAccount?.did]) + const follows = React.useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.follows) } + return [] }, [data]) const onRefresh = React.useCallback(async () => { @@ -47,7 +73,7 @@ export function ProfileFollows({name}: {name: string}) { }, [refetch, setIsPTRing]) const onEndReached = async () => { - if (isFetching || !hasNextPage || isError) return + if (isFetching || !hasNextPage || !!error) return try { await fetchNextPage() } catch (err) { @@ -55,57 +81,38 @@ export function ProfileFollows({name}: {name: string}) { } } - const renderItem = React.useCallback( - ({item}: {item: ActorDefs.ProfileViewBasic}) => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ), - [], - ) - - if (isFetchingDid || !isFetched) { - return <LoadingScreen /> - } - - // error - // = - if (resolveError || isError) { - return ( - <CenteredView> - <ErrorMessage - message={cleanError(resolveError || error)} - onPressTryAgain={onRefresh} - /> - </CenteredView> - ) - } - - // loaded - // = return ( - <List - data={follows} - keyExtractor={item => item.did} - refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - <View style={styles.footer}> - {(isFetching || isFetchingNextPage) && <ActivityIndicator />} - </View> + <> + <ListMaybePlaceholder + isLoading={isDidLoading || isFollowsLoading} + isEmpty={follows.length < 1} + isError={isError} + emptyType="results" + emptyMessage={ + isMe + ? _(msg`You are not following anyone.`) + : _(msg`This user isn't following anyone.`) + } + errorMessage={cleanError(resolveError || error)} + onRetry={isError ? refetch : undefined} + /> + {follows.length > 0 && ( + <List + data={follows} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTRing} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={4} + ListHeaderComponent={<ListHeaderDesktop title={_(msg`Following`)} />} + ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />} + // @ts-ignore our .web version only -prf + desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} + /> )} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> + </> ) } - -const styles = StyleSheet.create({ - footer: { - height: 200, - paddingTop: 20, - }, -}) 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 8f2c89499..3602cdb9a 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -21,7 +21,8 @@ import {useModerationOpts} from '#/state/queries/preferences' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileFollowMutationQueue} from '#/state/queries/profile' -import {Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' const OUTER_PADDING = 10 const INNER_PADDING = 14 @@ -98,9 +99,11 @@ export function ProfileHeaderSuggestedFollows({ <SuggestedFollowSkeleton /> </> ) : data ? ( - data.suggestions.map(profile => ( - <SuggestedFollow key={profile.did} profile={profile} /> - )) + data.suggestions + .filter(s => (s.associated?.labeler ? false : true)) + .map(profile => ( + <SuggestedFollow key={profile.did} profile={profile} /> + )) ) : ( <View /> )} @@ -168,6 +171,7 @@ function SuggestedFollow({ }) { const {track} = useAnalytics() const pal = usePalette('default') + const {_} = useLingui() const moderationOpts = useModerationOpts() const profile = useProfileShadow(profileUnshadowed) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( @@ -181,20 +185,20 @@ function SuggestedFollow({ await queueFollow() } catch (e: any) { if (e?.name !== 'AbortError') { - Toast.show('An issue occurred, please try again.') + Toast.show(_(msg`An issue occurred, please try again.`)) } } - }, [queueFollow, track]) + }, [queueFollow, track, _]) const onPressUnfollow = React.useCallback(async () => { try { await queueUnfollow() } catch (e: any) { if (e?.name !== 'AbortError') { - Toast.show('An issue occurred, please try again.') + Toast.show(_(msg`An issue occurred, please try again.`)) } } - }, [queueUnfollow]) + }, [queueUnfollow, _]) if (!moderationOpts) { return null @@ -217,7 +221,7 @@ function SuggestedFollow({ <UserAvatar size={60} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} /> <View style={{width: '100%', paddingVertical: 12}}> @@ -227,7 +231,7 @@ function SuggestedFollow({ numberOfLines={1}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, + moderation.ui('displayName'), )} </Text> <Text @@ -239,7 +243,7 @@ function SuggestedFollow({ </View> <Button - label={following ? 'Unfollow' : 'Follow'} + label={following ? _(msg`Unfollow`) : _(msg`Follow`)} type="inverted" labelStyle={{textAlign: 'center'}} onPress={following ? onPressUnfollow : onPressFollow} 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..529fc54e0 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -11,16 +11,12 @@ 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 {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' import {usePrefetchProfileQuery} from '#/state/queries/profile' interface PostMetaOpts { - author: { - avatar?: string - did: string - handle: string - displayName?: string | undefined - } + author: AppBskyActorDefs.ProfileViewBasic + moderation: ModerationDecision | undefined authorHasWarning: boolean postHref: string timestamp: string @@ -46,6 +42,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { avatar={opts.author.avatar} size={opts.avatarSize || 16} moderation={opts.avatarModeration} + type={opts.author.associated?.labeler ? 'labeler' : 'user'} /> </View> )} @@ -55,9 +52,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..8656c3f51 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,33 @@ let DefaultAvatar = ({ </Svg> ) } + if (type === 'labeler') { + return ( + <Svg + testID="userAvatarFallback" + width={size} + height={size} + viewBox="0 0 32 32" + fill="none" + stroke="none"> + <Rect + x="0" + y="0" + width="32" + height="32" + rx="3" + 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 +161,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/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx index 819840a46..b86cd46e1 100644 --- a/src/view/screens/LanguageSettings.tsx +++ b/src/view/screens/LanguageSettings.tsx @@ -97,7 +97,7 @@ export function LanguageSettingsScreen(_props: Props) { <Text style={[pal.text, s.pb10]}> <Trans> Select your app language for the default text to display in the - app + app. </Trans> </Text> @@ -296,7 +296,7 @@ export function LanguageSettingsScreen(_props: Props) { type="button" style={[pal.text, {flexShrink: 1, overflow: 'hidden'}]} numberOfLines={1}> - {myLanguages.length ? myLanguages : 'Select languages'} + {myLanguages.length ? myLanguages : _(msg`Select languages`)} </Text> </Button> </View> diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx deleted file mode 100644 index 928766c30..000000000 --- a/src/view/screens/Moderation.tsx +++ /dev/null @@ -1,304 +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" - accessibilityHint="" - accessibilityLabel={_(msg`Open 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" - accessibilityHint="" - accessibilityLabel={_(msg`Open 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/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 09d77987f..eb3b27048 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -131,7 +131,7 @@ export function ModerationBlockedAccounts({}: Props) { <Text type="lg" style={[pal.text, styles.emptyText]}> <Trans> You have not blocked any accounts yet. To block an account, go - to their profile and selected "Block account" from the menu on + to their profile and select "Block account" from the menu on their account. </Trans> </Text> diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 1aff19dd3..911ace778 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -130,8 +130,8 @@ export function ModerationMutedAccounts({}: Props) { <Text type="lg" style={[pal.text, styles.emptyText]}> <Trans> You have not muted any accounts yet. To mute an account, go to - their profile and selected "Mute account" from the menu on - their account. + their profile and select "Mute account" from the menu on their + account. </Trans> </Text> </View> diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index dfa840abb..7d51619b3 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -51,7 +51,13 @@ export const NotFoundScreen = () => { </Text> <Button type="primary" - label={canGoBack ? 'Go back' : 'Go home'} + label={canGoBack ? _(msg`Go Back`) : _(msg`Go Home`)} + accessibilityLabel={canGoBack ? _(msg`Go back`) : _(msg`Go home`)} + accessibilityHint={ + canGoBack + ? _(msg`Returns to previous page`) + : _(msg`Returns to home page`) + } onPress={onPressHome} /> </View> diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index aa09ab9ed..ba1fa130e 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -59,11 +59,7 @@ export function PostThreadScreen({route}: Props) { uri: thread.post.uri, cid: thread.post.cid, text: thread.record.text, - author: { - handle: thread.post.author.handle, - displayName: thread.post.author.displayName, - avatar: thread.post.author.avatar, - }, + author: thread.post.author, embed: thread.post.embed, }, onPost: () => 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 b3a7328c1..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 { @@ -108,8 +108,8 @@ export function ProfileFeedScreen(props: Props) { <View style={{flexDirection: 'row'}}> <Button type="default" - accessibilityLabel={_(msg`Go Back`)} - accessibilityHint="Return to previous page" + accessibilityLabel={_(msg`Go back`)} + accessibilityHint={_(msg`Returns to previous page`)} onPress={onPressBack} style={{flexShrink: 1}}> <Text type="button" style={pal.text}> @@ -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/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index 2cad08cb5..6f8ecc2e8 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -21,7 +21,7 @@ export const ProfileFollowersScreen = ({route}: Props) => { ) return ( - <View> + <View style={{flex: 1}}> <ViewHeader title={_(msg`Followers`)} /> <ProfileFollowersComponent name={name} /> </View> diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index 80502b98b..bdab20153 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -21,7 +21,7 @@ export const ProfileFollowsScreen = ({route}: Props) => { ) return ( - <View> + <View style={{flex: 1}}> <ViewHeader title={_(msg`Following`)} /> <ProfileFollowsComponent name={name} /> </View> diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 95046e5b4..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'} @@ -907,7 +913,7 @@ function ErrorScreen({error}: {error: string}) { <View style={{flexDirection: 'row'}}> <Button type="default" - accessibilityLabel={_(msg`Go Back`)} + accessibilityLabel={_(msg`Go back`)} accessibilityHint={_(msg`Return to previous page`)} onPress={onPressBack} style={{flexShrink: 1}}> diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 42eec53d3..d39f37ed7 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -141,6 +141,7 @@ function SearchScreenSuggestedFollows() { friends.slice(0, 4).map(friend => getSuggestedFollowsByActor(friend.did).then(foafsRes => { for (const user of foafsRes.suggestions) { + if (user.associated?.labeler) continue friendsOfFriends.set(user.did, user) } }), @@ -772,7 +773,7 @@ export function SearchScreen( {searchHistory.length > 0 && ( <View style={styles.searchHistoryContent}> <Text style={[pal.text, styles.searchHistoryTitle]}> - Recent Searches + <Trans>Recent Searches</Trans> </Text> {searchHistory.map((historyItem, index) => ( <View key={index} style={styles.historyItemContainer}> diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx index dca51c0dc..ba8fad2df 100644 --- a/src/view/screens/Settings/ExportCarDialog.tsx +++ b/src/view/screens/Settings/ExportCarDialog.tsx @@ -78,8 +78,9 @@ export function ExportCarDialog({ <InlineLink to="https://docs.bsky.app/blog/repo-export" style={[a.text_sm]}> - this blogpost. + this blogpost </InlineLink> + . </Trans> </P> diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 3b5e190c1..7e808f910 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -40,10 +40,7 @@ import { } from '#/state/preferences' import {useSession, useSessionApi, SessionAccount} from '#/state/session' import {useProfileQuery} from '#/state/queries/profile' -import { - useClearPreferencesMutation, - usePreferencesQuery, -} from '#/state/queries/preferences' +import {useClearPreferencesMutation} from '#/state/queries/preferences' // TODO import {useInviteCodesQuery} from '#/state/queries/invites' import {clear as clearStorage} from '#/state/persisted/store' import {clearLegacyStorage} from '#/state/persisted/legacy' @@ -85,7 +82,11 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { const contents = ( <View style={[pal.view, styles.linkCard]}> <View style={styles.avi}> - <UserAvatar size={40} avatar={profile?.avatar} /> + <UserAvatar + size={40} + avatar={profile?.avatar} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + /> </View> <View style={[s.flex1]}> <Text type="md-bold" style={pal.text}> @@ -156,7 +157,6 @@ export function SettingsScreen({}: Props) { const {screen, track} = useAnalytics() const {openModal} = useModalControls() const {isSwitchingAccounts, accounts, currentAccount} = useSession() - const {data: preferences} = usePreferencesQuery() const {mutate: clearPreferences} = useClearPreferencesMutation() // TODO // const {data: invites} = useInviteCodesQuery() @@ -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]) @@ -291,10 +295,7 @@ export function SettingsScreen({}: Props) { return ( <View style={s.hContentRegion} testID="settingsScreen"> <ExportCarDialog control={exportCarControl} /> - <BirthDateSettingsDialog - control={birthdayControl} - preferences={preferences} - /> + <BirthDateSettingsDialog control={birthdayControl} /> <SimpleViewHeader showBackButton={isMobile} @@ -486,20 +487,20 @@ export function SettingsScreen({}: Props) { label={_(msg`System`)} left onSelect={() => setColorMode('system')} - accessibilityHint={_(msg`Set color theme to system setting`)} + accessibilityHint={_(msg`Sets color theme to system setting`)} /> <SelectableBtn selected={colorMode === 'light'} label={_(msg`Light`)} onSelect={() => setColorMode('light')} - accessibilityHint={_(msg`Set color theme to light`)} + accessibilityHint={_(msg`Sets color theme to light`)} /> <SelectableBtn selected={colorMode === 'dark'} label={_(msg`Dark`)} right onSelect={() => setColorMode('dark')} - accessibilityHint={_(msg`Set color theme to dark`)} + accessibilityHint={_(msg`Sets color theme to dark`)} /> </View> </View> @@ -518,14 +519,14 @@ export function SettingsScreen({}: Props) { label={_(msg`Dim`)} left onSelect={() => setDarkTheme('dim')} - accessibilityHint={_(msg`Set dark theme to the dim theme`)} + accessibilityHint={_(msg`Sets dark theme to the dim theme`)} /> <SelectableBtn selected={darkTheme === 'dark'} label={_(msg`Dark`)} right onSelect={() => setDarkTheme('dark')} - accessibilityHint={_(msg`Set dark theme to the dark theme`)} + accessibilityHint={_(msg`Sets dark theme to the dark theme`)} /> </View> </View> @@ -545,8 +546,8 @@ export function SettingsScreen({}: Props) { ]} onPress={openFollowingFeedPreferences} accessibilityRole="button" - accessibilityHint="" - accessibilityLabel={_(msg`Opens the home feed preferences`)}> + accessibilityLabel={_(msg`Following feed preferences`)} + accessibilityHint={_(msg`Opens the Following feed preferences`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="sliders" @@ -566,8 +567,8 @@ export function SettingsScreen({}: Props) { ]} onPress={openThreadsPreferences} accessibilityRole="button" - accessibilityHint="" - accessibilityLabel={_(msg`Opens the threads preferences`)}> + accessibilityLabel={_(msg`Thread preferences`)} + accessibilityHint={_(msg`Opens the threads preferences`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon={['far', 'comments']} @@ -586,9 +587,10 @@ export function SettingsScreen({}: Props) { pal.view, isSwitchingAccounts && styles.dimmed, ]} - accessibilityHint="My Saved Feeds" - accessibilityLabel={_(msg`Opens screen with all saved feeds`)} - onPress={onPressSavedFeeds}> + onPress={onPressSavedFeeds} + accessibilityRole="button" + accessibilityLabel={_(msg`My saved feeds`)} + accessibilityHint={_(msg`Opens screen with all saved feeds`)}> <View style={[styles.iconContainer, pal.btn]}> <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> </View> @@ -687,7 +689,7 @@ export function SettingsScreen({}: Props) { onPress={onPressAppPasswords} accessibilityRole="button" accessibilityLabel={_(msg`App password settings`)} - accessibilityHint={_(msg`Opens the app password settings page`)}> + accessibilityHint={_(msg`Opens the app password settings`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="lock" @@ -708,7 +710,9 @@ export function SettingsScreen({}: Props) { onPress={isSwitchingAccounts ? undefined : onPressChangeHandle} accessibilityRole="button" accessibilityLabel={_(msg`Change handle`)} - accessibilityHint={_(msg`Choose a new Bluesky username or create`)}> + accessibilityHint={_( + msg`Opens modal for choosing a new Bluesky handle`, + )}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="at" @@ -744,7 +748,9 @@ export function SettingsScreen({}: Props) { onPress={() => openModal({name: 'change-password'})} accessibilityRole="button" accessibilityLabel={_(msg`Change password`)} - accessibilityHint={_(msg`Change your Bluesky password`)}> + accessibilityHint={_( + msg`Opens modal for changing your Bluesky password`, + )}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="lock" @@ -766,7 +772,7 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" accessibilityLabel={_(msg`Export my data`)} accessibilityHint={_( - msg`Download Bluesky account data (repository)`, + msg`Opens modal for downloading your Bluesky account data (repository)`, )}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon @@ -785,7 +791,7 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" accessibilityLabel={_(msg`Delete account`)} accessibilityHint={_( - msg`Opens modal for account deletion confirmation. Requires email code.`, + msg`Opens modal for account deletion confirmation. Requires email code`, )}> <View style={[styles.iconContainer, dangerBg]}> <FontAwesomeIcon @@ -803,8 +809,8 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={onPressSystemLog} accessibilityRole="button" - accessibilityHint="Open system log" - accessibilityLabel={_(msg`Opens the system log page`)}> + accessibilityLabel={_(msg`Open system log`)} + accessibilityHint={_(msg`Opens the system log page`)}> <Text type="lg" style={pal.text}> <Trans>System log</Trans> </Text> @@ -823,9 +829,19 @@ 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`)} + accessibilityLabel={_(msg`Reset preferences state`)} accessibilityHint={_(msg`Resets the preferences state`)}> <Text type="lg" style={pal.text}> <Trans>Reset preferences state</Trans> @@ -835,7 +851,7 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={onPressResetOnboarding} accessibilityRole="button" - accessibilityLabel={_(msg`Reset onboarding`)} + accessibilityLabel={_(msg`Reset onboarding state`)} accessibilityHint={_(msg`Resets the onboarding state`)}> <Text type="lg" style={pal.text}> <Trans>Reset onboarding state</Trans> @@ -846,7 +862,7 @@ export function SettingsScreen({}: Props) { onPress={clearAllLegacyStorage} accessibilityRole="button" accessibilityLabel={_(msg`Clear all legacy storage data`)} - accessibilityHint={_(msg`Clear all legacy storage data`)}> + accessibilityHint={_(msg`Clears all legacy storage data`)}> <Text type="lg" style={pal.text}> <Trans> Clear all legacy storage data (restart after this) @@ -858,7 +874,7 @@ export function SettingsScreen({}: Props) { onPress={clearAllStorage} accessibilityRole="button" accessibilityLabel={_(msg`Clear all storage data`)} - accessibilityHint={_(msg`Clear all storage data`)}> + accessibilityHint={_(msg`Clears all storage data`)}> <Text type="lg" style={pal.text}> <Trans>Clear all storage data (restart after this)</Trans> </Text> @@ -947,7 +963,7 @@ function EmailConfirmationNotice() { ]} accessibilityRole="button" accessibilityLabel={_(msg`Verify my email`)} - accessibilityHint="" + accessibilityHint={_(msg`Opens modal for email verification`)} onPress={() => openModal({name: 'verify-email'})}> <FontAwesomeIcon icon="envelope" 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/Drawer.tsx b/src/view/shell/Drawer.tsx index 2a37d1fe9..1bf5647f6 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -75,6 +75,7 @@ let DrawerProfileCard = ({ avatar={profile?.avatar} // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} + type={profile?.associated?.labeler ? 'labeler' : 'user'} /> <Text type="title-lg" @@ -93,10 +94,12 @@ let DrawerProfileCard = ({ {formatCountShortOnly(profile?.followersCount ?? 0)} </Text>{' '} {pluralize(profile?.followersCount || 0, 'follower')} ·{' '} - <Text type="xl-medium" style={pal.text}> - {formatCountShortOnly(profile?.followsCount ?? 0)} - </Text>{' '} - following + <Trans> + <Text type="xl-medium" style={pal.text}> + {formatCountShortOnly(profile?.followsCount ?? 0)} + </Text>{' '} + following + </Trans> </Text> </TouchableOpacity> ) diff --git a/src/view/shell/NavSignupCard.tsx b/src/view/shell/NavSignupCard.tsx index bae37e838..83d141498 100644 --- a/src/view/shell/NavSignupCard.tsx +++ b/src/view/shell/NavSignupCard.tsx @@ -58,7 +58,7 @@ let NavSignupCard = ({}: {}): React.ReactNode => { accessibilityHint={_(msg`Sign in`)} accessibilityLabel={_(msg`Sign in`)}> <Text type="md" style={[pal.text, s.bold]}> - Sign in + <Trans>Sign in</Trans> </Text> </Button> </View> diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 115faa296..8a19a0b4f 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -229,6 +229,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { size={27} // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} + type={profile?.associated?.labeler ? 'labeler' : 'user'} /> </View> ) : ( @@ -238,6 +239,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { size={28} // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} + type={profile?.associated?.labeler ? 'labeler' : 'user'} /> </View> )} diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index c56ba941e..097ca2fbf 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -64,7 +64,11 @@ function ProfileCard() { style={[styles.profileCard, !isDesktop && styles.profileCardTablet]} title={_(msg`My Profile`)} asAnchor> - <UserAvatar avatar={profile.avatar} size={size} /> + <UserAvatar + avatar={profile.avatar} + size={size} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + /> </Link> ) : ( <View style={[styles.profileCard, !isDesktop && styles.profileCardTablet]}> diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 4a9483733..0c5bd452f 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,8 @@ export function SearchProfileCard({ <UserAvatar size={40} avatar={profile.avatar} - moderation={moderation.avatar} + moderation={moderation.ui('avatar')} + type={profile.associated?.labeler ? 'labeler' : 'user'} /> <View style={{flex: 1}}> <Text @@ -121,7 +122,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> |