Merge remote-tracking branch 'upstream/main' into patch-3
This commit is contained in:
commit
ad43d594c9
174 changed files with 7262 additions and 5065 deletions
|
@ -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) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import {View, StyleSheet, ActivityIndicator} from 'react-native'
|
||||
import {ProfileModeration, AppBskyActorDefs} from '@atproto/api'
|
||||
import {ModerationDecision, AppBskyActorDefs} from '@atproto/api'
|
||||
import {Button} from '#/view/com/util/forms/Button'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
|
@ -19,7 +19,7 @@ import {logger} from '#/logger'
|
|||
|
||||
type Props = {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderation: ProfileModeration
|
||||
moderation: ModerationDecision
|
||||
onFollowStateChange: (props: {
|
||||
did: string
|
||||
following: boolean
|
||||
|
@ -63,7 +63,7 @@ function ProfileCard({
|
|||
moderation,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
moderation: ProfileModeration
|
||||
moderation: ModerationDecision
|
||||
onFollowStateChange: (props: {
|
||||
did: string
|
||||
following: boolean
|
||||
|
@ -115,7 +115,7 @@ function ProfileCard({
|
|||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
|
@ -126,7 +126,7 @@ function ProfileCard({
|
|||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.profile,
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text type="xl" style={[pal.textLight]} numberOfLines={1}>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -15,7 +15,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
|
|||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed'
|
||||
import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed'
|
||||
|
||||
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
const pal = usePalette('default')
|
||||
|
@ -86,7 +86,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
|||
<UserAvatar
|
||||
avatar={replyTo.author.avatar}
|
||||
size={50}
|
||||
moderation={replyTo.moderation?.avatar}
|
||||
moderation={replyTo.moderation?.ui('avatar')}
|
||||
/>
|
||||
<View style={styles.replyToPost}>
|
||||
<Text type="xl-medium" style={[pal.text]}>
|
||||
|
@ -103,7 +103,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
|||
{replyTo.text}
|
||||
</Text>
|
||||
</View>
|
||||
{images && !replyTo.moderation?.embed.blur && (
|
||||
{images && !replyTo.moderation?.ui('contentMedia').blur && (
|
||||
<ComposerReplyToImages images={images} showFull={showFull} />
|
||||
)}
|
||||
</View>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -6,9 +6,11 @@
|
|||
*
|
||||
*/
|
||||
import React from 'react'
|
||||
import {createHitslop} from 'lib/constants'
|
||||
import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native'
|
||||
import {t} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {createHitslop} from '#/lib/constants'
|
||||
|
||||
type Props = {
|
||||
onRequestClose: () => void
|
||||
|
@ -16,20 +18,23 @@ type Props = {
|
|||
|
||||
const HIT_SLOP = createHitslop(16)
|
||||
|
||||
const ImageDefaultHeader = ({onRequestClose}: Props) => (
|
||||
<SafeAreaView style={styles.root}>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={onRequestClose}
|
||||
hitSlop={HIT_SLOP}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t`Close image`}
|
||||
accessibilityHint={t`Closes viewer for header image`}
|
||||
onAccessibilityEscape={onRequestClose}>
|
||||
<Text style={styles.closeText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
)
|
||||
const ImageDefaultHeader = ({onRequestClose}: Props) => {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<SafeAreaView style={styles.root}>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={onRequestClose}
|
||||
hitSlop={HIT_SLOP}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Close image`)}
|
||||
accessibilityHint={_(msg`Closes viewer for header image`)}
|
||||
onAccessibilityEscape={onRequestClose}>
|
||||
<Text style={styles.closeText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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} />
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -1,142 +0,0 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {s} from 'lib/styles'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {listUriToHref} from 'lib/strings/url-helpers'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
|
||||
export const snapPoints = [300]
|
||||
|
||||
export function Component({
|
||||
context,
|
||||
moderation,
|
||||
}: {
|
||||
context: 'account' | 'content'
|
||||
moderation: ModerationUI
|
||||
}) {
|
||||
const {closeModal} = useModalControls()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
let name
|
||||
let description
|
||||
if (!moderation.cause) {
|
||||
name = _(msg`Content Warning`)
|
||||
description = _(
|
||||
msg`Moderator has chosen to set a general warning on the content.`,
|
||||
)
|
||||
} else if (moderation.cause.type === 'blocking') {
|
||||
if (moderation.cause.source.type === 'list') {
|
||||
const list = moderation.cause.source.list
|
||||
name = _(msg`User Blocked by List`)
|
||||
description = (
|
||||
<Trans>
|
||||
This user is included in the{' '}
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href={listUriToHref(list.uri)}
|
||||
text={list.name}
|
||||
style={pal.link}
|
||||
/>{' '}
|
||||
list which you have blocked.
|
||||
</Trans>
|
||||
)
|
||||
} else {
|
||||
name = _(msg`User Blocked`)
|
||||
description = _(
|
||||
msg`You have blocked this user. You cannot view their content.`,
|
||||
)
|
||||
}
|
||||
} else if (moderation.cause.type === 'blocked-by') {
|
||||
name = _(msg`User Blocks You`)
|
||||
description = _(
|
||||
msg`This user has blocked you. You cannot view their content.`,
|
||||
)
|
||||
} else if (moderation.cause.type === 'block-other') {
|
||||
name = _(msg`Content Not Available`)
|
||||
description = _(
|
||||
msg`This content is not available because one of the users involved has blocked the other.`,
|
||||
)
|
||||
} else if (moderation.cause.type === 'muted') {
|
||||
if (moderation.cause.source.type === 'list') {
|
||||
const list = moderation.cause.source.list
|
||||
name = _(msg`Account Muted by List`)
|
||||
description = (
|
||||
<Trans>
|
||||
This user is included in the{' '}
|
||||
<TextLink
|
||||
type="2xl"
|
||||
href={listUriToHref(list.uri)}
|
||||
text={list.name}
|
||||
style={pal.link}
|
||||
/>{' '}
|
||||
list which you have muted.
|
||||
</Trans>
|
||||
)
|
||||
} else {
|
||||
name = _(msg`Account Muted`)
|
||||
description = _(msg`You have muted this user.`)
|
||||
}
|
||||
} else {
|
||||
name = moderation.cause.labelDef.strings[context].en.name
|
||||
description = moderation.cause.labelDef.strings[context].en.description
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="moderationDetailsModal"
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingHorizontal: isMobile ? 14 : 0,
|
||||
},
|
||||
pal.view,
|
||||
]}>
|
||||
<Text type="title-xl" style={[pal.text, styles.title]}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text type="2xl" style={[pal.text, styles.description]}>
|
||||
{description}
|
||||
</Text>
|
||||
<View style={s.flex1} />
|
||||
<Button
|
||||
type="primary"
|
||||
style={styles.btn}
|
||||
onPress={() => {
|
||||
closeModal()
|
||||
}}>
|
||||
<Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
|
||||
<Trans>Okay</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
btn: {
|
||||
paddingVertical: 14,
|
||||
marginTop: isWeb ? 40 : 0,
|
||||
marginBottom: isWeb ? 0 : 40,
|
||||
},
|
||||
})
|
|
@ -1,100 +0,0 @@
|
|||
import React from 'react'
|
||||
import {View, TouchableOpacity, StyleSheet} from 'react-native'
|
||||
import {TextInput} from '../util'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {CharProgress} from '../../composer/char-progress/CharProgress'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {s} from 'lib/styles'
|
||||
import {SendReportButton} from './SendReportButton'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
export function InputIssueDetails({
|
||||
details,
|
||||
setDetails,
|
||||
goBack,
|
||||
submitReport,
|
||||
isProcessing,
|
||||
}: {
|
||||
details: string | undefined
|
||||
setDetails: (v: string) => void
|
||||
goBack: () => void
|
||||
submitReport: () => void
|
||||
isProcessing: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginTop: isMobile ? 12 : 0,
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
testID="addDetailsBtn"
|
||||
style={[s.mb10, styles.backBtn]}
|
||||
onPress={goBack}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add details`)}
|
||||
accessibilityHint={_(msg`Add more details to your report`)}>
|
||||
<FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} />
|
||||
<Text style={[pal.text, s.f18, pal.link]}>
|
||||
{' '}
|
||||
<Trans>Back</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={[pal.btn, styles.detailsInputContainer]}>
|
||||
<TextInput
|
||||
accessibilityLabel={_(msg`Text input field`)}
|
||||
accessibilityHint={_(msg`Enter a reason for reporting this post.`)}
|
||||
placeholder={_(msg`Enter a reason or any other details here.`)}
|
||||
placeholderTextColor={pal.textLight.color}
|
||||
value={details}
|
||||
onChangeText={setDetails}
|
||||
autoFocus={true}
|
||||
numberOfLines={3}
|
||||
multiline={true}
|
||||
textAlignVertical="top"
|
||||
maxLength={300}
|
||||
style={[styles.detailsInput, pal.text]}
|
||||
/>
|
||||
<View style={styles.detailsInputBottomBar}>
|
||||
<View style={styles.charCounter}>
|
||||
<CharProgress count={details?.length || 0} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<SendReportButton onPress={submitReport} isProcessing={isProcessing} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
detailsInputContainer: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
detailsInput: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
borderRadius: 8,
|
||||
minHeight: 100,
|
||||
fontSize: 16,
|
||||
},
|
||||
detailsInputBottomBar: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
charCounter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingRight: 10,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
})
|
|
@ -1,228 +0,0 @@
|
|||
import React, {useState, useMemo} from 'react'
|
||||
import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {s} from 'lib/styles'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import * as Toast from '../../util/Toast'
|
||||
import {ErrorMessage} from '../../util/error/ErrorMessage'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {SendReportButton} from './SendReportButton'
|
||||
import {InputIssueDetails} from './InputIssueDetails'
|
||||
import {ReportReasonOptions} from './ReasonOptions'
|
||||
import {CollectionId} from './types'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||
|
||||
export const snapPoints = [575]
|
||||
|
||||
const CollectionNames = {
|
||||
[CollectionId.FeedGenerator]: <Trans>Feed</Trans>,
|
||||
[CollectionId.Profile]: <Trans>Profile</Trans>,
|
||||
[CollectionId.List]: <Trans>List</Trans>,
|
||||
[CollectionId.Post]: <Trans context="description">Post</Trans>,
|
||||
}
|
||||
|
||||
type ReportComponentProps =
|
||||
| {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
| {
|
||||
did: string
|
||||
}
|
||||
|
||||
export function Component(content: ReportComponentProps) {
|
||||
const {closeModal} = useModalControls()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [showDetailsInput, setShowDetailsInput] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [issue, setIssue] = useState<string>('')
|
||||
const [details, setDetails] = useState<string>('')
|
||||
const isAccountReport = 'did' in content
|
||||
const subjectKey = isAccountReport ? content.did : content.uri
|
||||
const atUri = useMemo(
|
||||
() => (!isAccountReport ? new AtUri(subjectKey) : null),
|
||||
[isAccountReport, subjectKey],
|
||||
)
|
||||
|
||||
const submitReport = async () => {
|
||||
setError('')
|
||||
if (!issue) {
|
||||
return
|
||||
}
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
if (issue === '__copyright__') {
|
||||
Linking.openURL(DMCA_LINK)
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
const $type = !isAccountReport
|
||||
? 'com.atproto.repo.strongRef'
|
||||
: 'com.atproto.admin.defs#repoRef'
|
||||
await getAgent().createModerationReport({
|
||||
reasonType: issue,
|
||||
subject: {
|
||||
$type,
|
||||
...content,
|
||||
},
|
||||
reason: details,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg`Thank you for your report! We'll look into it promptly.`),
|
||||
)
|
||||
|
||||
closeModal()
|
||||
return
|
||||
} catch (e: any) {
|
||||
setError(cleanError(e))
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
setShowDetailsInput(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView testID="reportModal" style={[s.flex1, pal.view]}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
isMobile && {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
]}>
|
||||
{showDetailsInput ? (
|
||||
<InputIssueDetails
|
||||
details={details}
|
||||
setDetails={setDetails}
|
||||
goBack={goBack}
|
||||
submitReport={submitReport}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
) : (
|
||||
<SelectIssue
|
||||
setShowDetailsInput={setShowDetailsInput}
|
||||
error={error}
|
||||
issue={issue}
|
||||
setIssue={setIssue}
|
||||
submitReport={submitReport}
|
||||
isProcessing={isProcessing}
|
||||
atUri={atUri}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
// If no atUri is passed, that means the reporting collection is account
|
||||
const getCollectionNameForReport = (atUri: AtUri | null) => {
|
||||
if (!atUri) return <Trans>Account</Trans>
|
||||
// Generic fallback for any collection being reported
|
||||
return (
|
||||
CollectionNames[atUri.collection as CollectionId] || <Trans>Content</Trans>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectIssue = ({
|
||||
error,
|
||||
setShowDetailsInput,
|
||||
issue,
|
||||
setIssue,
|
||||
submitReport,
|
||||
isProcessing,
|
||||
atUri,
|
||||
}: {
|
||||
error: string | undefined
|
||||
setShowDetailsInput: (v: boolean) => void
|
||||
issue: string | undefined
|
||||
setIssue: (v: string) => void
|
||||
submitReport: () => void
|
||||
isProcessing: boolean
|
||||
atUri: AtUri | null
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const collectionName = getCollectionNameForReport(atUri)
|
||||
const onSelectIssue = (v: string) => setIssue(v)
|
||||
const goToDetails = () => {
|
||||
if (issue === '__copyright__') {
|
||||
Linking.openURL(DMCA_LINK)
|
||||
return
|
||||
}
|
||||
setShowDetailsInput(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={[pal.text, styles.title]}>
|
||||
<Trans>Report {collectionName}</Trans>
|
||||
</Text>
|
||||
<Text style={[pal.textLight, styles.description]}>
|
||||
<Trans>What is the issue with this {collectionName}?</Trans>
|
||||
</Text>
|
||||
<View style={{marginBottom: 10}}>
|
||||
<ReportReasonOptions
|
||||
atUri={atUri}
|
||||
selectedIssue={issue}
|
||||
onSelectIssue={onSelectIssue}
|
||||
/>
|
||||
</View>
|
||||
{error ? <ErrorMessage message={error} /> : undefined}
|
||||
{/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */}
|
||||
{issue || !atUri ? (
|
||||
<>
|
||||
<SendReportButton
|
||||
onPress={submitReport}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="addDetailsBtn"
|
||||
style={styles.addDetailsBtn}
|
||||
onPress={goToDetails}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add details`)}
|
||||
accessibilityHint={_(msg`Add more details to your report`)}>
|
||||
<Text style={[s.f18, pal.link]}>
|
||||
<Trans>Add details to report</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : undefined}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
fontSize: 17,
|
||||
paddingHorizontal: 22,
|
||||
marginBottom: 10,
|
||||
},
|
||||
addDetailsBtn: {
|
||||
padding: 14,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
})
|
|
@ -1,126 +0,0 @@
|
|||
import {View} from 'react-native'
|
||||
import React, {useMemo} from 'react'
|
||||
import {AtUri, ComAtprotoModerationDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette'
|
||||
import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup'
|
||||
import {CollectionId} from './types'
|
||||
|
||||
type ReasonMap = Record<string, {title: JSX.Element; description: JSX.Element}>
|
||||
const CommonReasons = {
|
||||
[ComAtprotoModerationDefs.REASONRUDE]: {
|
||||
title: <Trans>Anti-Social Behavior</Trans>,
|
||||
description: <Trans>Harassment, trolling, or intolerance</Trans>,
|
||||
},
|
||||
[ComAtprotoModerationDefs.REASONVIOLATION]: {
|
||||
title: <Trans>Illegal and Urgent</Trans>,
|
||||
description: <Trans>Glaring violations of law or terms of service</Trans>,
|
||||
},
|
||||
[ComAtprotoModerationDefs.REASONOTHER]: {
|
||||
title: <Trans>Other</Trans>,
|
||||
description: <Trans>An issue not included in these options</Trans>,
|
||||
},
|
||||
}
|
||||
const CollectionToReasonsMap: Record<string, ReasonMap> = {
|
||||
[CollectionId.Post]: {
|
||||
[ComAtprotoModerationDefs.REASONSPAM]: {
|
||||
title: <Trans>Spam</Trans>,
|
||||
description: <Trans>Excessive mentions or replies</Trans>,
|
||||
},
|
||||
[ComAtprotoModerationDefs.REASONSEXUAL]: {
|
||||
title: <Trans>Unwanted Sexual Content</Trans>,
|
||||
description: <Trans>Nudity or pornography not labeled as such</Trans>,
|
||||
},
|
||||
__copyright__: {
|
||||
title: <Trans>Copyright Violation</Trans>,
|
||||
description: <Trans>Contains copyrighted material</Trans>,
|
||||
},
|
||||
...CommonReasons,
|
||||
},
|
||||
[CollectionId.List]: {
|
||||
...CommonReasons,
|
||||
[ComAtprotoModerationDefs.REASONVIOLATION]: {
|
||||
title: <Trans>Name or Description Violates Community Standards</Trans>,
|
||||
description: <Trans>Terms used violate community standards</Trans>,
|
||||
},
|
||||
},
|
||||
}
|
||||
const AccountReportReasons = {
|
||||
[ComAtprotoModerationDefs.REASONMISLEADING]: {
|
||||
title: <Trans>Misleading Account</Trans>,
|
||||
description: (
|
||||
<Trans>Impersonation or false claims about identity or affiliation</Trans>
|
||||
),
|
||||
},
|
||||
[ComAtprotoModerationDefs.REASONSPAM]: {
|
||||
title: <Trans>Frequently Posts Unwanted Content</Trans>,
|
||||
description: <Trans>Spam; excessive mentions or replies</Trans>,
|
||||
},
|
||||
[ComAtprotoModerationDefs.REASONVIOLATION]: {
|
||||
title: <Trans>Name or Description Violates Community Standards</Trans>,
|
||||
description: <Trans>Terms used violate community standards</Trans>,
|
||||
},
|
||||
}
|
||||
|
||||
const Option = ({
|
||||
pal,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
pal: UsePaletteValue
|
||||
description: JSX.Element
|
||||
title: JSX.Element
|
||||
}) => {
|
||||
return (
|
||||
<View>
|
||||
<Text style={pal.text} type="md-bold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={pal.textLight}>{description}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// This is mostly just content copy without almost any logic
|
||||
// so this may grow over time and it makes sense to split it up into its own file
|
||||
// to keep it separate from the actual reporting modal logic
|
||||
const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) =>
|
||||
useMemo(() => {
|
||||
let items: ReasonMap = {...CommonReasons}
|
||||
// If no atUri is passed, that means the reporting collection is account
|
||||
if (!atUri) {
|
||||
items = {...AccountReportReasons}
|
||||
}
|
||||
|
||||
if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) {
|
||||
items = {...CollectionToReasonsMap[atUri.collection]}
|
||||
}
|
||||
|
||||
return Object.entries(items).map(([key, {title, description}]) => ({
|
||||
key,
|
||||
label: <Option pal={pal} title={title} description={description} />,
|
||||
}))
|
||||
}, [pal, atUri])
|
||||
|
||||
export const ReportReasonOptions = ({
|
||||
atUri,
|
||||
selectedIssue,
|
||||
onSelectIssue,
|
||||
}: {
|
||||
atUri: AtUri | null
|
||||
selectedIssue?: string
|
||||
onSelectIssue: (key: string) => void
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri)
|
||||
return (
|
||||
<RadioGroup
|
||||
items={ITEMS}
|
||||
onSelect={onSelectIssue}
|
||||
testID="reportReasonRadios"
|
||||
initialSelection={selectedIssue}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
}
|
|
@ -11,7 +11,7 @@ import {
|
|||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
ModerationOpts,
|
||||
ProfileModeration,
|
||||
ModerationDecision,
|
||||
moderateProfile,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
} from '@atproto/api'
|
||||
|
@ -54,7 +54,7 @@ interface Author {
|
|||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
moderation: ProfileModeration
|
||||
moderation: ModerationDecision
|
||||
}
|
||||
|
||||
let FeedItem = ({
|
||||
|
@ -336,7 +336,7 @@ function CondensedAuthorsList({
|
|||
did={authors[0].did}
|
||||
handle={authors[0].handle}
|
||||
avatar={authors[0].avatar}
|
||||
moderation={authors[0].moderation.avatar}
|
||||
moderation={authors[0].moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
@ -354,7 +354,7 @@ function CondensedAuthorsList({
|
|||
<UserAvatar
|
||||
size={35}
|
||||
avatar={author.avatar}
|
||||
moderation={author.moderation.avatar}
|
||||
moderation={author.moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
@ -412,7 +412,7 @@ function ExpandedAuthorsList({
|
|||
<UserAvatar
|
||||
size={35}
|
||||
avatar={author.avatar}
|
||||
moderation={author.moderation.avatar}
|
||||
moderation={author.moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
<View style={s.flex1}>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -106,11 +106,12 @@ export function PostThread({
|
|||
? moderatePost(rootPost, moderationOpts)
|
||||
: undefined
|
||||
|
||||
const cause = mod?.content.cause
|
||||
|
||||
return cause
|
||||
? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated'
|
||||
: false
|
||||
return !!mod
|
||||
?.ui('contentList')
|
||||
.blurs.find(
|
||||
cause =>
|
||||
cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
|
||||
)
|
||||
}, [rootPost, moderationOpts])
|
||||
|
||||
useSetTitle(
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
RichText as RichTextAPI,
|
||||
PostModeration,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
|
@ -19,14 +19,14 @@ import {niceDate} from 'lib/strings/time'
|
|||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {countLines, pluralize} from 'lib/strings/helpers'
|
||||
import {isEmbedByEmbedder} from 'lib/embeds'
|
||||
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostHider} from '../util/moderation/PostHider'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||
import {PostHider} from '../../../components/moderation/PostHider'
|
||||
import {ContentHider} from '../../../components/moderation/ContentHider'
|
||||
import {PostAlerts} from '../../../components/moderation/PostAlerts'
|
||||
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {formatCount} from '../util/numeric/format'
|
||||
|
@ -147,7 +147,7 @@ let PostThreadItemLoaded = ({
|
|||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
record: AppBskyFeedPost.Record
|
||||
richText: RichTextAPI
|
||||
moderation: PostModeration
|
||||
moderation: ModerationDecision
|
||||
treeView: boolean
|
||||
depth: number
|
||||
prevPost: ThreadPost | undefined
|
||||
|
@ -175,7 +175,6 @@ let PostThreadItemLoaded = ({
|
|||
const itemTitle = _(msg`Post by ${post.author.handle}`)
|
||||
const authorHref = makeProfileLink(post.author)
|
||||
const authorTitle = post.author.handle
|
||||
const isAuthorMuted = post.author.viewer?.muted
|
||||
const likesHref = React.useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
|
||||
|
@ -256,7 +255,7 @@ let PostThreadItemLoaded = ({
|
|||
did={post.author.did}
|
||||
handle={post.author.handle}
|
||||
avatar={post.author.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
|
@ -271,35 +270,12 @@ let PostThreadItemLoaded = ({
|
|||
{sanitizeDisplayName(
|
||||
post.author.displayName ||
|
||||
sanitizeHandle(post.author.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
{isAuthorMuted && (
|
||||
<View
|
||||
style={[
|
||||
pal.viewLight,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
marginRight: 4,
|
||||
},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'eye-slash']}
|
||||
size={12}
|
||||
color={pal.colors.textLight}
|
||||
/>
|
||||
<Text type="sm-medium" style={pal.textLight}>
|
||||
<Trans>Muted</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Link style={s.flex1} href={authorHref} title={authorTitle}>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{sanitizeHandle(post.author.handle, '@')}
|
||||
|
@ -312,15 +288,16 @@ let PostThreadItemLoaded = ({
|
|||
)}
|
||||
</View>
|
||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
<LabelsOnMyPost post={post} />
|
||||
<ContentHider
|
||||
moderation={moderation.content}
|
||||
modui={moderation.ui('contentView')}
|
||||
ignoreMute
|
||||
style={styles.contentHider}
|
||||
childContainerStyle={styles.contentHiderChild}>
|
||||
<PostAlerts
|
||||
moderation={moderation.content}
|
||||
modui={moderation.ui('contentView')}
|
||||
includeMute
|
||||
style={styles.alert}
|
||||
style={[a.pt_2xs, a.pb_sm]}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View
|
||||
|
@ -338,18 +315,9 @@ let PostThreadItemLoaded = ({
|
|||
</View>
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<ContentHider
|
||||
moderation={moderation.embed}
|
||||
moderationDecisions={moderation.decisions}
|
||||
ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
|
||||
ignoreQuoteDecisions
|
||||
style={s.mb10}>
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation.embed}
|
||||
moderationDecisions={moderation.decisions}
|
||||
/>
|
||||
</ContentHider>
|
||||
<View style={[a.pb_sm]}>
|
||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
||||
</View>
|
||||
)}
|
||||
</ContentHider>
|
||||
<ExpandedPostDetails
|
||||
|
@ -432,7 +400,8 @@ let PostThreadItemLoaded = ({
|
|||
<PostHider
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
href={postHref}
|
||||
moderation={moderation.content}
|
||||
style={[pal.view]}
|
||||
modui={moderation.ui('contentList')}
|
||||
iconSize={isThreadedChild ? 26 : 38}
|
||||
iconStyles={
|
||||
isThreadedChild
|
||||
|
@ -482,7 +451,7 @@ let PostThreadItemLoaded = ({
|
|||
did={post.author.did}
|
||||
handle={post.author.handle}
|
||||
avatar={post.author.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
|
||||
{showChildReplyLine && (
|
||||
|
@ -508,19 +477,21 @@ let PostThreadItemLoaded = ({
|
|||
}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
authorHasWarning={!!post.author.labels?.length}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={postHref}
|
||||
showAvatar={isThreadedChild}
|
||||
avatarModeration={moderation.avatar}
|
||||
avatarModeration={moderation.ui('avatar')}
|
||||
avatarSize={28}
|
||||
displayNameType="md-bold"
|
||||
displayNameStyle={isThreadedChild && s.ml2}
|
||||
style={isThreadedChild && s.mb2}
|
||||
/>
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostAlerts
|
||||
moderation={moderation.content}
|
||||
style={styles.alert}
|
||||
modui={moderation.ui('contentList')}
|
||||
style={[a.pt_xs, a.pb_sm]}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
|
@ -542,18 +513,9 @@ let PostThreadItemLoaded = ({
|
|||
/>
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<ContentHider
|
||||
style={styles.contentHider}
|
||||
moderation={moderation.embed}
|
||||
moderationDecisions={moderation.decisions}
|
||||
ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
|
||||
ignoreQuoteDecisions>
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation.embed}
|
||||
moderationDecisions={moderation.decisions}
|
||||
/>
|
||||
</ContentHider>
|
||||
<View style={[a.pb_xs]}>
|
||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
||||
</View>
|
||||
)}
|
||||
<PostCtrls
|
||||
post={post}
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
PostModeration,
|
||||
ModerationDecision,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
|
@ -14,8 +14,9 @@ import {UserInfoText} from '../util/UserInfoText'
|
|||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||
import {ContentHider} from '../../../components/moderation/ContentHider'
|
||||
import {PostAlerts} from '../../../components/moderation/PostAlerts'
|
||||
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
|
@ -93,7 +94,7 @@ function PostInner({
|
|||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
record: AppBskyFeedPost.Record
|
||||
richText: RichTextAPI
|
||||
moderation: PostModeration
|
||||
moderation: ModerationDecision
|
||||
showReplyLine?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
|
@ -142,12 +143,13 @@ function PostInner({
|
|||
did={post.author.did}
|
||||
handle={post.author.handle}
|
||||
avatar={post.author.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
authorHasWarning={!!post.author.labels?.length}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={itemHref}
|
||||
|
@ -176,11 +178,15 @@ function PostInner({
|
|||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<ContentHider
|
||||
moderation={moderation.content}
|
||||
modui={moderation.ui('contentView')}
|
||||
style={styles.contentHider}
|
||||
childContainerStyle={styles.contentHiderChild}>
|
||||
<PostAlerts moderation={moderation.content} style={styles.alert} />
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentView')}
|
||||
style={[a.py_xs]}
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
|
@ -202,17 +208,7 @@ function PostInner({
|
|||
/>
|
||||
) : undefined}
|
||||
{post.embed ? (
|
||||
<ContentHider
|
||||
moderation={moderation.embed}
|
||||
moderationDecisions={moderation.decisions}
|
||||
ignoreQuoteDecisions
|
||||
style={styles.contentHider}>
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation.embed}
|
||||
moderationDecisions={moderation.decisions}
|
||||
/>
|
||||
</ContentHider>
|
||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
||||
) : null}
|
||||
</ContentHider>
|
||||
<PostCtrls
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
PostModeration,
|
||||
ModerationDecision,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
|
@ -18,8 +18,9 @@ import {UserInfoText} from '../util/UserInfoText'
|
|||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {ContentHider} from '../util/moderation/ContentHider'
|
||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '../../../components/moderation/PostAlerts'
|
||||
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import {s} from 'lib/styles'
|
||||
|
@ -27,13 +28,11 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {isEmbedByEmbedder} from 'lib/embeds'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
import {countLines} from 'lib/strings/helpers'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
|
||||
import {FeedNameText} from '../util/FeedInfoText'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
@ -50,7 +49,7 @@ export function FeedItem({
|
|||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
||||
moderation: PostModeration
|
||||
moderation: ModerationDecision
|
||||
isThreadChild?: boolean
|
||||
isThreadLastChild?: boolean
|
||||
isThreadParent?: boolean
|
||||
|
@ -100,7 +99,7 @@ let FeedItemInner = ({
|
|||
record: AppBskyFeedPost.Record
|
||||
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
||||
richText: RichTextAPI
|
||||
moderation: PostModeration
|
||||
moderation: ModerationDecision
|
||||
isThreadChild?: boolean
|
||||
isThreadLastChild?: boolean
|
||||
isThreadParent?: boolean
|
||||
|
@ -108,14 +107,10 @@ let FeedItemInner = ({
|
|||
const {openComposer} = useComposerControls()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const href = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||
}, [post.uri, post.author])
|
||||
const isModeratedPost =
|
||||
moderation.decisions.post.cause?.type === 'label' &&
|
||||
moderation.decisions.post.cause.label.src !== currentAccount?.did
|
||||
|
||||
const replyAuthorDid = useMemo(() => {
|
||||
if (!record?.reply) {
|
||||
|
@ -148,7 +143,7 @@ let FeedItemInner = ({
|
|||
borderColor: pal.colors.border,
|
||||
paddingBottom:
|
||||
isThreadLastChild || (!isThreadChild && !isThreadParent)
|
||||
? 6
|
||||
? 8
|
||||
: undefined,
|
||||
},
|
||||
isThreadChild ? styles.outerSmallTop : undefined,
|
||||
|
@ -229,6 +224,7 @@ let FeedItemInner = ({
|
|||
numberOfLines={1}
|
||||
text={sanitizeDisplayName(
|
||||
reason.by.displayName || sanitizeHandle(reason.by.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
href={makeProfileLink(reason.by)}
|
||||
/>
|
||||
|
@ -246,7 +242,7 @@ let FeedItemInner = ({
|
|||
did={post.author.did}
|
||||
handle={post.author.handle}
|
||||
avatar={post.author.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
{isThreadParent && (
|
||||
<View
|
||||
|
@ -264,6 +260,7 @@ let FeedItemInner = ({
|
|||
<View style={styles.layoutContent}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
authorHasWarning={!!post.author.labels?.length}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={href}
|
||||
|
@ -295,6 +292,7 @@ let FeedItemInner = ({
|
|||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostContent
|
||||
moderation={moderation}
|
||||
richText={richText}
|
||||
|
@ -306,9 +304,6 @@ let FeedItemInner = ({
|
|||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
showAppealLabelItem={
|
||||
post.author.did === currentAccount?.did && isModeratedPost
|
||||
}
|
||||
logContext="FeedItem"
|
||||
/>
|
||||
</View>
|
||||
|
@ -324,7 +319,7 @@ let PostContent = ({
|
|||
postEmbed,
|
||||
postAuthor,
|
||||
}: {
|
||||
moderation: PostModeration
|
||||
moderation: ModerationDecision
|
||||
richText: RichTextAPI
|
||||
postEmbed: AppBskyFeedDefs.PostView['embed']
|
||||
postAuthor: AppBskyFeedDefs.PostView['author']
|
||||
|
@ -342,10 +337,10 @@ let PostContent = ({
|
|||
return (
|
||||
<ContentHider
|
||||
testID="contentHider-post"
|
||||
moderation={moderation.content}
|
||||
modui={moderation.ui('contentList')}
|
||||
ignoreMute
|
||||
childContainerStyle={styles.contentHiderChild}>
|
||||
<PostAlerts moderation={moderation.content} style={styles.alert} />
|
||||
<PostAlerts modui={moderation.ui('contentList')} style={[a.py_xs]} />
|
||||
{richText.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
|
@ -367,19 +362,9 @@ let PostContent = ({
|
|||
/>
|
||||
) : undefined}
|
||||
{postEmbed ? (
|
||||
<ContentHider
|
||||
testID="contentHider-embed"
|
||||
moderation={moderation.embed}
|
||||
moderationDecisions={moderation.decisions}
|
||||
ignoreMute={isEmbedByEmbedder(postEmbed, postAuthor.did)}
|
||||
ignoreQuoteDecisions
|
||||
style={styles.embed}>
|
||||
<PostEmbeds
|
||||
embed={postEmbed}
|
||||
moderation={moderation.embed}
|
||||
moderationDecisions={moderation.decisions}
|
||||
/>
|
||||
</ContentHider>
|
||||
<View style={[a.pb_sm]}>
|
||||
<PostEmbeds embed={postEmbed} moderation={moderation} />
|
||||
</View>
|
||||
) : null}
|
||||
</ContentHider>
|
||||
)
|
||||
|
|
|
@ -3,7 +3,8 @@ import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
|||
import {
|
||||
AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
ProfileModeration,
|
||||
ModerationCause,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
|
@ -14,16 +15,13 @@ import {FollowButton} from './FollowButton'
|
|||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {
|
||||
describeModerationCause,
|
||||
getProfileModerationCauses,
|
||||
getModerationCauseKey,
|
||||
} from 'lib/moderation'
|
||||
import {getModerationCauseKey, isJustAMute} from 'lib/moderation'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
|
||||
export function ProfileCard({
|
||||
testID,
|
||||
|
@ -33,6 +31,7 @@ export function ProfileCard({
|
|||
noBorder,
|
||||
followers,
|
||||
renderButton,
|
||||
onPress,
|
||||
style,
|
||||
}: {
|
||||
testID?: string
|
||||
|
@ -44,6 +43,7 @@ export function ProfileCard({
|
|||
renderButton?: (
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
|
||||
) => React.ReactNode
|
||||
onPress?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
|
@ -53,11 +53,8 @@ export function ProfileCard({
|
|||
return null
|
||||
}
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
if (
|
||||
!noModFilter &&
|
||||
moderation.account.filter &&
|
||||
moderation.account.cause?.type !== 'muted'
|
||||
) {
|
||||
const modui = moderation.ui('profileList')
|
||||
if (!noModFilter && modui.filter && !isJustAMute(modui)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -73,6 +70,7 @@ export function ProfileCard({
|
|||
]}
|
||||
href={makeProfileLink(profile)}
|
||||
title={profile.handle}
|
||||
onBeforePress={onPress}
|
||||
asAnchor
|
||||
anchorNoUnderline>
|
||||
<View style={styles.layout}>
|
||||
|
@ -80,7 +78,7 @@ export function ProfileCard({
|
|||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
|
@ -91,7 +89,7 @@ export function ProfileCard({
|
|||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.profile,
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
|
@ -119,17 +117,17 @@ export function ProfileCard({
|
|||
)
|
||||
}
|
||||
|
||||
function ProfileCardPills({
|
||||
export function ProfileCardPills({
|
||||
followedBy,
|
||||
moderation,
|
||||
}: {
|
||||
followedBy: boolean
|
||||
moderation: ProfileModeration
|
||||
moderation: ModerationDecision
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
const causes = getProfileModerationCauses(moderation)
|
||||
if (!followedBy && !causes.length) {
|
||||
const modui = moderation.ui('profileList')
|
||||
if (!followedBy && !modui.inform && !modui.alert) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -142,19 +140,41 @@ function ProfileCardPills({
|
|||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{causes.map(cause => {
|
||||
const desc = describeModerationCause(cause, 'account')
|
||||
return (
|
||||
<View
|
||||
style={[s.mt5, pal.btn, styles.pill]}
|
||||
key={getModerationCauseKey(cause)}>
|
||||
<Text type="xs" style={pal.text}>
|
||||
{cause?.type === 'label' ? '⚠' : ''}
|
||||
{desc.name}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
{modui.alerts.map(alert => (
|
||||
<ProfileCardPillModerationCause
|
||||
key={getModerationCauseKey(alert)}
|
||||
cause={alert}
|
||||
severity="alert"
|
||||
/>
|
||||
))}
|
||||
{modui.informs.map(inform => (
|
||||
<ProfileCardPillModerationCause
|
||||
key={getModerationCauseKey(inform)}
|
||||
cause={inform}
|
||||
severity="inform"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCardPillModerationCause({
|
||||
cause,
|
||||
severity,
|
||||
}: {
|
||||
cause: ModerationCause
|
||||
severity: 'alert' | 'inform'
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {name} = useModerationCauseDescription(cause)
|
||||
return (
|
||||
<View
|
||||
style={[s.mt5, pal.btn, styles.pill]}
|
||||
key={getModerationCauseKey(cause)}>
|
||||
<Text type="xs" style={pal.text}>
|
||||
{severity === 'alert' ? '⚠ ' : ''}
|
||||
{name}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
@ -177,7 +197,7 @@ function FollowersList({
|
|||
f,
|
||||
mod: moderateProfile(f, moderationOpts),
|
||||
}))
|
||||
.filter(({mod}) => !mod.account.filter)
|
||||
.filter(({mod}) => !mod.ui('profileList').filter)
|
||||
}, [followers, moderationOpts])
|
||||
|
||||
if (!followersWithMods?.length) {
|
||||
|
@ -199,7 +219,11 @@ function FollowersList({
|
|||
{followersWithMods.slice(0, 3).map(({f, mod}) => (
|
||||
<View key={f.did} style={styles.followedByAviContainer}>
|
||||
<View style={[styles.followedByAvi, pal.view]}>
|
||||
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
|
||||
<UserAvatar
|
||||
avatar={f.avatar}
|
||||
size={32}
|
||||
moderation={mod.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
@ -212,11 +236,13 @@ export function ProfileCardWithFollowBtn({
|
|||
noBg,
|
||||
noBorder,
|
||||
followers,
|
||||
onPress,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
noBg?: boolean
|
||||
noBorder?: boolean
|
||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||
onPress?: () => void
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const isMe = profile.did === currentAccount?.did
|
||||
|
@ -234,6 +260,7 @@ export function ProfileCardWithFollowBtn({
|
|||
<FollowButton profile={profileShadow} logContext="ProfileCard" />
|
||||
)
|
||||
}
|
||||
onPress={onPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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},
|
||||
})
|
|
@ -219,7 +219,7 @@ function SuggestedFollow({
|
|||
<UserAvatar
|
||||
size={60}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
|
||||
<View style={{width: '100%', paddingVertical: 12}}>
|
||||
|
@ -229,7 +229,7 @@ function SuggestedFollow({
|
|||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.profile,
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
|
|
|
@ -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.`,
|
||||
)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -11,7 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
|
|||
import {isAndroid, isWeb} from 'platform/detection'
|
||||
import {TimeElapsed} from './TimeElapsed'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {ModerationDecision, ModerationUI} from '@atproto/api'
|
||||
import {usePrefetchProfileQuery} from '#/state/queries/profile'
|
||||
|
||||
interface PostMetaOpts {
|
||||
|
@ -21,6 +21,7 @@ interface PostMetaOpts {
|
|||
handle: string
|
||||
displayName?: string | undefined
|
||||
}
|
||||
moderation: ModerationDecision | undefined
|
||||
authorHasWarning: boolean
|
||||
postHref: string
|
||||
timestamp: string
|
||||
|
@ -55,9 +56,14 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
|||
style={[pal.text, opts.displayNameStyle]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}
|
||||
disableMismatchWarning
|
||||
text={
|
||||
<>
|
||||
{sanitizeDisplayName(displayName)}
|
||||
{sanitizeDisplayName(
|
||||
displayName,
|
||||
opts.moderation?.ui('displayName'),
|
||||
)}
|
||||
|
||||
<Text
|
||||
type="md"
|
||||
numberOfLines={1}
|
||||
|
|
|
@ -24,9 +24,9 @@ import {
|
|||
} from '#/components/icons/Camera'
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {useTheme} from '#/alf'
|
||||
import {useTheme, tokens} from '#/alf'
|
||||
|
||||
export type UserAvatarType = 'user' | 'algo' | 'list'
|
||||
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
||||
|
||||
interface BaseUserAvatarProps {
|
||||
type?: UserAvatarType
|
||||
|
@ -101,6 +101,29 @@ let DefaultAvatar = ({
|
|||
</Svg>
|
||||
)
|
||||
}
|
||||
if (type === 'labeler') {
|
||||
return (
|
||||
<Svg
|
||||
testID="userAvatarFallback"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
stroke="none">
|
||||
<Path
|
||||
d="M28 0H4C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H28C30.2091 32 32 30.2091 32 28V4C32 1.79086 30.2091 0 28 0Z"
|
||||
fill={tokens.color.temp_purple}
|
||||
/>
|
||||
<Path
|
||||
d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Svg
|
||||
testID="userAvatarFallback"
|
||||
|
@ -134,7 +157,7 @@ let UserAvatar = ({
|
|||
const backgroundColor = pal.colors.backgroundLight
|
||||
|
||||
const aviStyle = useMemo(() => {
|
||||
if (type === 'algo' || type === 'list') {
|
||||
if (type === 'algo' || type === 'list' || type === 'labeler') {
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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`)}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
923
src/view/screens/DebugMod.tsx
Normal file
923
src/view/screens/DebugMod.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -1,306 +0,0 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {s} from 'lib/styles'
|
||||
import {CenteredView} from '../com/util/Views'
|
||||
import {ViewHeader} from '../com/util/ViewHeader'
|
||||
import {Link, TextLink} from '../com/util/Link'
|
||||
import {Text} from '../com/util/text/Text'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ToggleButton} from '../com/util/forms/ToggleButton'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
useProfileQuery,
|
||||
useProfileUpdateMutation,
|
||||
} from '#/state/queries/profile'
|
||||
import {ScrollView} from '../com/util/Views'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
||||
export function ModerationScreen({}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {screen, track} = useAnalytics()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {openModal} = useModalControls()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
screen('Moderation')
|
||||
setMinimalShellMode(false)
|
||||
}, [screen, setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const onPressContentFiltering = React.useCallback(() => {
|
||||
track('Moderation:ContentfilteringButtonClicked')
|
||||
openModal({name: 'content-filtering-settings'})
|
||||
}, [track, openModal])
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
s.hContentRegion,
|
||||
pal.border,
|
||||
isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
|
||||
]}
|
||||
testID="moderationScreen">
|
||||
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
|
||||
<ScrollView contentContainerStyle={[styles.noBorder]}>
|
||||
<View style={styles.spacer} />
|
||||
<TouchableOpacity
|
||||
testID="contentFilteringBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
onPress={onPressContentFiltering}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Content filtering`)}
|
||||
accessibilityHint={_(
|
||||
msg`Opens modal for content filtering settings`,
|
||||
)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="eye"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Content filtering</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="mutedWordsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
onPress={() => mutedWordsDialogControl.open()}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={_(msg`Muted words & tags`)}
|
||||
accessibilityHint={_(msg`Open modal for muted words settings`)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="filter"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Muted words & tags</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Link
|
||||
testID="moderationlistsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
href="/moderation/modlists">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="users-slash"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Moderation lists</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
<Link
|
||||
testID="mutedAccountsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
href="/moderation/muted-accounts">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="user-slash"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Muted accounts</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
<Link
|
||||
testID="blockedAccountsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
href="/moderation/blocked-accounts">
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ban"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Blocked accounts</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
<Text
|
||||
type="xl-bold"
|
||||
style={[
|
||||
pal.text,
|
||||
{
|
||||
paddingHorizontal: 18,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
]}>
|
||||
<Trans>Logged-out visibility</Trans>
|
||||
</Text>
|
||||
<PwiOptOut />
|
||||
</ScrollView>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function PwiOptOut() {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const updateProfile = useProfileUpdateMutation()
|
||||
|
||||
const isOptedOut =
|
||||
profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
|
||||
const canToggle = profile && !updateProfile.isPending
|
||||
|
||||
const onToggleOptOut = React.useCallback(() => {
|
||||
if (!profile) {
|
||||
return
|
||||
}
|
||||
let wasAdded = false
|
||||
updateProfile.mutate({
|
||||
profile,
|
||||
updates: existing => {
|
||||
// create labels attr if needed
|
||||
existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
|
||||
? existing.labels
|
||||
: {
|
||||
$type: 'com.atproto.label.defs#selfLabels',
|
||||
values: [],
|
||||
}
|
||||
|
||||
// toggle the label
|
||||
const hasLabel = existing.labels.values.some(
|
||||
l => l.val === '!no-unauthenticated',
|
||||
)
|
||||
if (hasLabel) {
|
||||
wasAdded = false
|
||||
existing.labels.values = existing.labels.values.filter(
|
||||
l => l.val !== '!no-unauthenticated',
|
||||
)
|
||||
} else {
|
||||
wasAdded = true
|
||||
existing.labels.values.push({val: '!no-unauthenticated'})
|
||||
}
|
||||
|
||||
// delete if no longer needed
|
||||
if (existing.labels.values.length === 0) {
|
||||
delete existing.labels
|
||||
}
|
||||
return existing
|
||||
},
|
||||
checkCommitted: res => {
|
||||
const exists = !!res.data.labels?.some(
|
||||
l => l.val === '!no-unauthenticated',
|
||||
)
|
||||
return exists === wasAdded
|
||||
},
|
||||
})
|
||||
}, [updateProfile, profile])
|
||||
|
||||
return (
|
||||
<View style={[pal.view, styles.toggleCard]}>
|
||||
<View
|
||||
style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}>
|
||||
<ToggleButton
|
||||
type="default-light"
|
||||
label={_(
|
||||
msg`Discourage apps from showing my account to logged-out users`,
|
||||
)}
|
||||
labelType="lg"
|
||||
isSelected={isOptedOut}
|
||||
onPress={canToggle ? onToggleOptOut : undefined}
|
||||
style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]}
|
||||
/>
|
||||
{updateProfile.isPending && <ActivityIndicator />}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
paddingLeft: 66,
|
||||
paddingRight: 12,
|
||||
paddingBottom: 10,
|
||||
marginBottom: 64,
|
||||
}}>
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>
|
||||
Bluesky will not show your profile and posts to logged-out users.
|
||||
Other apps may not honor this request. This does not make your
|
||||
account private.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[pal.textLight, {fontWeight: '500'}]}>
|
||||
<Trans>
|
||||
Note: Bluesky is an open and public network. This setting only
|
||||
limits the visibility of your content on the Bluesky app and
|
||||
website, and other apps may not respect this setting. Your content
|
||||
may still be shown to logged-out users by other apps and websites.
|
||||
</Trans>
|
||||
</Text>
|
||||
<TextLink
|
||||
style={pal.link}
|
||||
href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"
|
||||
text={_(msg`Learn more about what is public on Bluesky.`)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
desktopContainer: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
spacer: {
|
||||
height: 6,
|
||||
},
|
||||
linkCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 18,
|
||||
marginBottom: 1,
|
||||
},
|
||||
toggleCard: {
|
||||
paddingVertical: 8,
|
||||
paddingTop: 2,
|
||||
paddingHorizontal: 6,
|
||||
marginBottom: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 30,
|
||||
marginRight: 12,
|
||||
},
|
||||
noBorder: {
|
||||
borderBottomWidth: 0,
|
||||
borderRightWidth: 0,
|
||||
borderLeftWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
})
|
|
@ -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}))
|
||||
|
|
|
@ -35,7 +35,7 @@ import {ComposeIcon2} from 'lib/icons'
|
|||
import {logger} from '#/logger'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
||||
import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {
|
||||
|
@ -155,7 +155,7 @@ export function ProfileFeedScreenInner({
|
|||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const reportDialogControl = useReportDialogControl()
|
||||
const {openComposer} = useComposerControls()
|
||||
const {track} = useAnalytics()
|
||||
const feedSectionRef = React.useRef<SectionRef>(null)
|
||||
|
@ -253,13 +253,8 @@ export function ProfileFeedScreenInner({
|
|||
}, [feedInfo, track])
|
||||
|
||||
const onPressReport = React.useCallback(() => {
|
||||
if (!feedInfo) return
|
||||
openModal({
|
||||
name: 'report',
|
||||
uri: feedInfo.uri,
|
||||
cid: feedInfo.cid,
|
||||
})
|
||||
}, [openModal, feedInfo])
|
||||
reportDialogControl.open()
|
||||
}, [reportDialogControl])
|
||||
|
||||
const onCurrentPageSelected = React.useCallback(
|
||||
(index: number) => {
|
||||
|
@ -400,6 +395,14 @@ export function ProfileFeedScreenInner({
|
|||
|
||||
return (
|
||||
<View style={s.hContentRegion}>
|
||||
<ReportDialog
|
||||
control={reportDialogControl}
|
||||
params={{
|
||||
type: 'feedgen',
|
||||
uri: feedInfo.uri,
|
||||
cid: feedInfo.cid,
|
||||
}}
|
||||
/>
|
||||
<PagerWithHeader
|
||||
items={SECTION_TITLES}
|
||||
isHeaderReady={true}
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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])
|
||||
|
@ -826,6 +830,16 @@ export function SettingsScreen({}: Props) {
|
|||
<Trans>Storybook</Trans>
|
||||
</Text>
|
||||
</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}
|
||||
|
|
|
@ -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]}>
|
||||
|
@ -148,6 +157,14 @@ export function Buttons() {
|
|||
label="Link out">
|
||||
<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"
|
||||
|
@ -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]}>
|
||||
|
@ -183,6 +208,14 @@ export function Buttons() {
|
|||
label="Link out">
|
||||
<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"
|
||||
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -67,6 +67,7 @@ export function Storybook() {
|
|||
</Button>
|
||||
</View>
|
||||
|
||||
<Dialogs />
|
||||
<ThemeProvider theme="light">
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
|
|
|
@ -11,7 +11,7 @@ import {useNavigation, StackActions} from '@react-navigation/native'
|
|||
import {
|
||||
AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
ProfileModeration,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
@ -86,7 +86,7 @@ export function SearchProfileCard({
|
|||
moderation,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderation: ProfileModeration
|
||||
moderation: ModerationDecision
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
|
@ -111,7 +111,7 @@ export function SearchProfileCard({
|
|||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
<View style={{flex: 1}}>
|
||||
<Text
|
||||
|
@ -121,7 +121,7 @@ export function SearchProfileCard({
|
|||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.profile,
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
|
|
|
@ -101,8 +101,8 @@ function ShellInner() {
|
|||
<Composer winHeight={winDim.height} />
|
||||
<ModalsContainer />
|
||||
<MutedWordsDialog />
|
||||
<PortalOutlet />
|
||||
<Lightbox />
|
||||
<PortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue