3p moderation services [WIP] (#2550)

* Add modservice screen and profile-header-card

* Drop the guidelines for now

* Remove ununsed constants

* Add label & label group descriptions

* Not found state

* Reorg, add icon

* Subheader

* Header

* Complete header

* Clean up

* Add all groups

* Fix scroll view

* Dialogs side quest

* Remove log

* Add (WIP) debug mod page

* Dialog solution

* Add note

* Clean up and reorganize localized moderation strings

* Memoize

* Add example

* Add first ReportDialog screen

* Report dialog step 2

* Submit

* Integrate updates

* Move moderation screen

* Migrate buttons

* Migrate everything

* Rough sketch

* Fix types

* Update atoms values

* Abstract ModerationServiceCard

* Hook up data to settings page

* Handle subscription

* Rough enablement

* Rough enablement

* Some validation, fixes

* More work on the mod debug screen

* Hook up data

* Update invalidation

* Hook up data to ReportDialog

* Fix native error

* Refactor/rewrite the entire moderation-application system

* Fix toggles

* Add copyright and other option to report

* Handle reports on profile vs content

* Little cleanup

* Get post hiding back in gear

* Better loading flow on Mod screen

* Clean up Mod screen

* Clean up ProfileMod screen

* Handle muting correctly

* Update enablement on ProfileMod screen

* Improve Moderation screen and dialog

* Styling, handle disabled labelers

* Rework list of labels on own content

* Use moderateNotification()

* ReportDialog updates

* Fix button overflow

* Simplify the ProfileModerationService ui

* Mod screen design

* Move moderation card from the profile header to a tab

* Small tweaks to the moderation screen

* Enable toggle on mod page

* Add notifs to debugmod and dont filter notifs from followed users

* Add moderator-service profile view

* Wire up more of the modservice data to profiles

* A bunch of speculative non-working UI

* Cleanup: delete old code

* Update ModerationDetailsDialog

* Update ReportDialog

* Update LabelsOnMe dialog

* Handle ReportDialog load better

* Rename LabelsOnMeDialog, fix close

* Experiment to put labeling under a tab of a normal profile

* Moderator variation of profile

* Remove dead code and start moving toward latest modsdk

* Remove a bunch of now-dead label strings

* Update ModDebug to be a bit more intuitive and support custom labels

* Minor ui tweaks

* Improve consistency of display name blurring

* Fix profile-card warning rendering

* More debugmod UI tuning

* Update to use new labeler semantics

* Delete some dead code and do some refactoring

* Update profile to pull from labeler definition

* Implement new label config controls (wip)

* Tweak ui

* Implement preference controls on labelers

* Rework label pref ui

* Get moderation screen working

* Add asyncstorage query persistence

* Implement label handling

* Small cleanup

* Implement Likes dialog

* Fix: remove text outside of text element

* Cleanup

* Fix likes dialog on mobile

* Implement the label appeal flow

* Get report flow working again with temporarily fixed report options

* Update onboarding

* Enforce limit of ten labeler subscriptions

* Fix type errors

* Fix lint errors

* Improve types of RQ

* Some work on Likes dialog, needs discussion

* Bit of ReportDialog cleanup

* Replace non-single-path SVG

* Update nudity descriptions

* Update to use new sdk updates

* Add adult-content-enabled behavior to label config

* Use the default setting of custom labels

* Handle global moderation label prefs with the global settings

* Fix missing postAuthor

* Fix empty moderation page

* Add mutewords control back to Mod screen

* Tweak adult setting styles

* Remove deprecated global labels

* Handle underage users on mod screen

* Adjust font sizes

* Swap in RichText

* Like button improvements

* Tweaks to Labeler profile

* Design tweaks for mod pref dialog

* Add tertiary button color

* Switch moderation UIs to tertiary color

* Update mutewords and hiddenposts to use the new sdk

* Add test-environment mod authority

* Switch 'gore' to 'graphic-media'

* Move nudity out of the adult content control

* Remove focus styles from buttons - let the browser behavior handle it

* Fixes to the adult content age-gating in moderaiton

* Ditch tertiary button color, lighten secondary button

* Fix some colors

* Remove focused overrides from toggles

* Liked by screen

* Rework the moderationlabelpref

* Fix optimistic like

* Cleanup

* Change how onboarding handles adult content enabled/disabled

* Add special handling of the mod authorities

* Tweaks

* Update the default labeler avatar to a shield

* Add route to go server

* Avoid dups due to bad config

* Fix attrs

* Fix: dont try to detect link/label mismatches on post meta

* Correctly show the label behavior when adult content is disabled

* Readd the local hiddenPosts handling

* WIP

* Fix bad merge

* Conten hider design tweaks

* Fix text string breakage

* Adjust source text in ContentHider

* Fix link bug

* Design tweaks to ContentHider and ModDetailsDialog

* Adjust spacing of inform badges

* Adjust spacing of embeds in posts

* Style tweaks to post/profile alerts

* Labels on me and dialog

* Remove bad focus styles from post dropdown

* Better spacing solution

* Tune moderation UIs

* Moderation UI tweaks for mobile

* Move labelers query on Mod screen

* Update to use new SDK appLabelers semantics

* Implement report submission

* Replace the report modal entirely with the report dialog

* Add @ to mod details dialog handle

* Bump SDK package

* Remove silly type

* Add to AWS build CI

* Fix ToggleButton overflow

* Clean up ModServiceCard, rename to LabelingServiceCard

* Hackfix to translate gore labels to graphic-media

* Tune content hider sizing on web desktop

* Handle self labels

* Fix spacing below text-only posts

* Fix: send appeals to the right labeler

* Give mod page links interactive states

* Fix references

* Remove focus handling

* Remove remnant

* Remove the like count from the subscribed labeler listing

* Bump @atproto/api@0.11.1

* Remove extra @

* Fix: persist labels to local storage to reduce coverage gaps

* update dipendencies

* revert dipendencies

* Add some explainers on how blocking affects labelers

* Tweak copy

* Fix underline color in header

* Fix profile menu

* Handle card overflow

* Remove metrics from header

* Mute 'account' not 'user'

* Show metrics if self

* Show the labels tab on logged out view

* Fix bad merge

* Use purple theming on labelers

* Tighten space on LabelerCard

* Set staleTime to 6hrs for labeler details

* Memoize the memoizers

* Drop staleTime to 60s

* Move label defs into a context to reduce recomputes

* Submit view tweaks

* Move labeler fetch below auth

* Mitigation: hardcode the bluesky moderation labeler name

* Bump sdk

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Hailey's fix for incorrect profile tabs

Co-authored-by: Hailey <me@haileyok.com>

* Feedback

* Fix borders, add bottom space

* Hailey's fix pt 2

Co-authored-by: Hailey <me@haileyok.com>

* Fix post tabs

* Integrate feedback pt 1

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 2

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 3

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 4

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 5

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 6

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 7

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 8

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Format

* Integrate new bday modal

* Use public agent for getServices

* Update casing

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Paul Frazee 2024-03-18 12:46:28 -07:00 committed by GitHub
parent d5ebbeb3fc
commit 20d463ff2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
165 changed files with 7034 additions and 5009 deletions

View file

@ -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) {

View file

@ -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'
@ -18,7 +18,7 @@ import {logger} from '#/logger'
type Props = {
profile: AppBskyActorDefs.ProfileViewBasic
moderation: ProfileModeration
moderation: ModerationDecision
onFollowStateChange: (props: {
did: string
following: boolean
@ -62,7 +62,7 @@ function ProfileCard({
moderation,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
moderation: ProfileModeration
moderation: ModerationDecision
onFollowStateChange: (props: {
did: string
following: boolean
@ -113,7 +113,7 @@ function ProfileCard({
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
<View style={styles.layoutContent}>
@ -124,7 +124,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}>

View file

@ -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'

View file

@ -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>

View file

@ -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,
},
})

View file

@ -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,
},
})

View file

@ -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} />

View file

@ -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') {

View file

@ -1,142 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {listUriToHref} from 'lib/strings/url-helpers'
import {Button} from '../util/forms/Button'
import {useModalControls} from '#/state/modals'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
export const snapPoints = [300]
export function Component({
context,
moderation,
}: {
context: 'account' | 'content'
moderation: ModerationUI
}) {
const {closeModal} = useModalControls()
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
let name
let description
if (!moderation.cause) {
name = _(msg`Content Warning`)
description = _(
msg`Moderator has chosen to set a general warning on the content.`,
)
} else if (moderation.cause.type === 'blocking') {
if (moderation.cause.source.type === 'list') {
const list = moderation.cause.source.list
name = _(msg`User Blocked by List`)
description = (
<Trans>
This user is included in the{' '}
<TextLink
type="2xl"
href={listUriToHref(list.uri)}
text={list.name}
style={pal.link}
/>{' '}
list which you have blocked.
</Trans>
)
} else {
name = _(msg`User Blocked`)
description = _(
msg`You have blocked this user. You cannot view their content.`,
)
}
} else if (moderation.cause.type === 'blocked-by') {
name = _(msg`User Blocks You`)
description = _(
msg`This user has blocked you. You cannot view their content.`,
)
} else if (moderation.cause.type === 'block-other') {
name = _(msg`Content Not Available`)
description = _(
msg`This content is not available because one of the users involved has blocked the other.`,
)
} else if (moderation.cause.type === 'muted') {
if (moderation.cause.source.type === 'list') {
const list = moderation.cause.source.list
name = _(msg`Account Muted by List`)
description = (
<Trans>
This user is included in the{' '}
<TextLink
type="2xl"
href={listUriToHref(list.uri)}
text={list.name}
style={pal.link}
/>{' '}
list which you have muted.
</Trans>
)
} else {
name = _(msg`Account Muted`)
description = _(msg`You have muted this user.`)
}
} else {
name = moderation.cause.labelDef.strings[context].en.name
description = moderation.cause.labelDef.strings[context].en.description
}
return (
<View
testID="moderationDetailsModal"
style={[
styles.container,
{
paddingHorizontal: isMobile ? 14 : 0,
},
pal.view,
]}>
<Text type="title-xl" style={[pal.text, styles.title]}>
{name}
</Text>
<Text type="2xl" style={[pal.text, styles.description]}>
{description}
</Text>
<View style={s.flex1} />
<Button
type="primary"
style={styles.btn}
onPress={() => {
closeModal()
}}>
<Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
Okay
</Text>
</Button>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
marginBottom: 12,
},
description: {
textAlign: 'center',
},
btn: {
paddingVertical: 14,
marginTop: isWeb ? 40 : 0,
marginBottom: isWeb ? 0 : 40,
},
})

View file

@ -1,100 +0,0 @@
import React from 'react'
import {View, TouchableOpacity, StyleSheet} from 'react-native'
import {TextInput} from '../util'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CharProgress} from '../../composer/char-progress/CharProgress'
import {Text} from '../../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {SendReportButton} from './SendReportButton'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function InputIssueDetails({
details,
setDetails,
goBack,
submitReport,
isProcessing,
}: {
details: string | undefined
setDetails: (v: string) => void
goBack: () => void
submitReport: () => void
isProcessing: boolean
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
return (
<View
style={{
marginTop: isMobile ? 12 : 0,
}}>
<TouchableOpacity
testID="addDetailsBtn"
style={[s.mb10, styles.backBtn]}
onPress={goBack}
accessibilityRole="button"
accessibilityLabel={_(msg`Add details`)}
accessibilityHint="Add more details to your report">
<FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} />
<Text style={[pal.text, s.f18, pal.link]}>
{' '}
<Trans>Back</Trans>
</Text>
</TouchableOpacity>
<View style={[pal.btn, styles.detailsInputContainer]}>
<TextInput
accessibilityLabel={_(msg`Text input field`)}
accessibilityHint="Enter a reason for reporting this post."
placeholder="Enter a reason or any other details here."
placeholderTextColor={pal.textLight.color}
value={details}
onChangeText={setDetails}
autoFocus={true}
numberOfLines={3}
multiline={true}
textAlignVertical="top"
maxLength={300}
style={[styles.detailsInput, pal.text]}
/>
<View style={styles.detailsInputBottomBar}>
<View style={styles.charCounter}>
<CharProgress count={details?.length || 0} />
</View>
</View>
</View>
<SendReportButton onPress={submitReport} isProcessing={isProcessing} />
</View>
)
}
const styles = StyleSheet.create({
backBtn: {
flexDirection: 'row',
alignItems: 'center',
},
detailsInputContainer: {
borderRadius: 8,
},
detailsInput: {
paddingHorizontal: 12,
paddingTop: 12,
paddingBottom: 12,
borderRadius: 8,
minHeight: 100,
fontSize: 16,
},
detailsInputBottomBar: {
alignSelf: 'flex-end',
},
charCounter: {
flexDirection: 'row',
alignItems: 'center',
paddingRight: 10,
paddingBottom: 8,
},
})

View file

@ -1,223 +0,0 @@
import React, {useState, useMemo} from 'react'
import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AtUri} from '@atproto/api'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {Text} from '../../util/text/Text'
import * as Toast from '../../util/Toast'
import {ErrorMessage} from '../../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {SendReportButton} from './SendReportButton'
import {InputIssueDetails} from './InputIssueDetails'
import {ReportReasonOptions} from './ReasonOptions'
import {CollectionId} from './types'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {getAgent} from '#/state/session'
const DMCA_LINK = 'https://bsky.social/about/support/copyright'
export const snapPoints = [575]
const CollectionNames = {
[CollectionId.FeedGenerator]: 'Feed',
[CollectionId.Profile]: 'Profile',
[CollectionId.List]: 'List',
[CollectionId.Post]: 'Post',
}
type ReportComponentProps =
| {
uri: string
cid: string
}
| {
did: string
}
export function Component(content: ReportComponentProps) {
const {closeModal} = useModalControls()
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const [isProcessing, setIsProcessing] = useState(false)
const [showDetailsInput, setShowDetailsInput] = useState(false)
const [error, setError] = useState<string>('')
const [issue, setIssue] = useState<string>('')
const [details, setDetails] = useState<string>('')
const isAccountReport = 'did' in content
const subjectKey = isAccountReport ? content.did : content.uri
const atUri = useMemo(
() => (!isAccountReport ? new AtUri(subjectKey) : null),
[isAccountReport, subjectKey],
)
const submitReport = async () => {
setError('')
if (!issue) {
return
}
setIsProcessing(true)
try {
if (issue === '__copyright__') {
Linking.openURL(DMCA_LINK)
closeModal()
return
}
const $type = !isAccountReport
? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef'
await getAgent().createModerationReport({
reasonType: issue,
subject: {
$type,
...content,
},
reason: details,
})
Toast.show("Thank you for your report! We'll look into it promptly.")
closeModal()
return
} catch (e: any) {
setError(cleanError(e))
setIsProcessing(false)
}
}
const goBack = () => {
setShowDetailsInput(false)
}
return (
<ScrollView testID="reportModal" style={[s.flex1, pal.view]}>
<View
style={[
styles.container,
isMobile && {
paddingBottom: 40,
},
]}>
{showDetailsInput ? (
<InputIssueDetails
details={details}
setDetails={setDetails}
goBack={goBack}
submitReport={submitReport}
isProcessing={isProcessing}
/>
) : (
<SelectIssue
setShowDetailsInput={setShowDetailsInput}
error={error}
issue={issue}
setIssue={setIssue}
submitReport={submitReport}
isProcessing={isProcessing}
atUri={atUri}
/>
)}
</View>
</ScrollView>
)
}
// If no atUri is passed, that means the reporting collection is account
const getCollectionNameForReport = (atUri: AtUri | null) => {
if (!atUri) return 'Account'
// Generic fallback for any collection being reported
return CollectionNames[atUri.collection as CollectionId] || 'Content'
}
const SelectIssue = ({
error,
setShowDetailsInput,
issue,
setIssue,
submitReport,
isProcessing,
atUri,
}: {
error: string | undefined
setShowDetailsInput: (v: boolean) => void
issue: string | undefined
setIssue: (v: string) => void
submitReport: () => void
isProcessing: boolean
atUri: AtUri | null
}) => {
const pal = usePalette('default')
const {_} = useLingui()
const collectionName = getCollectionNameForReport(atUri)
const onSelectIssue = (v: string) => setIssue(v)
const goToDetails = () => {
if (issue === '__copyright__') {
Linking.openURL(DMCA_LINK)
return
}
setShowDetailsInput(true)
}
return (
<>
<Text style={[pal.text, styles.title]}>
<Trans>Report {collectionName}</Trans>
</Text>
<Text style={[pal.textLight, styles.description]}>
<Trans>What is the issue with this {collectionName}?</Trans>
</Text>
<View style={{marginBottom: 10}}>
<ReportReasonOptions
atUri={atUri}
selectedIssue={issue}
onSelectIssue={onSelectIssue}
/>
</View>
{error ? <ErrorMessage message={error} /> : undefined}
{/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */}
{issue || !atUri ? (
<>
<SendReportButton
onPress={submitReport}
isProcessing={isProcessing}
/>
<TouchableOpacity
testID="addDetailsBtn"
style={styles.addDetailsBtn}
onPress={goToDetails}
accessibilityRole="button"
accessibilityLabel={_(msg`Add details`)}
accessibilityHint="Add more details to your report">
<Text style={[s.f18, pal.link]}>
<Trans>Add details to report</Trans>
</Text>
</TouchableOpacity>
</>
) : undefined}
</>
)
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 10,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
description: {
textAlign: 'center',
fontSize: 17,
paddingHorizontal: 22,
marginBottom: 10,
},
addDetailsBtn: {
padding: 14,
alignSelf: 'center',
},
})

View file

@ -1,123 +0,0 @@
import {View} from 'react-native'
import React, {useMemo} from 'react'
import {AtUri, ComAtprotoModerationDefs} from '@atproto/api'
import {Text} from '../../util/text/Text'
import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette'
import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup'
import {CollectionId} from './types'
type ReasonMap = Record<string, {title: string; description: string}>
const CommonReasons = {
[ComAtprotoModerationDefs.REASONRUDE]: {
title: 'Anti-Social Behavior',
description: 'Harassment, trolling, or intolerance',
},
[ComAtprotoModerationDefs.REASONVIOLATION]: {
title: 'Illegal and Urgent',
description: 'Glaring violations of law or terms of service',
},
[ComAtprotoModerationDefs.REASONOTHER]: {
title: 'Other',
description: 'An issue not included in these options',
},
}
const CollectionToReasonsMap: Record<string, ReasonMap> = {
[CollectionId.Post]: {
[ComAtprotoModerationDefs.REASONSPAM]: {
title: 'Spam',
description: 'Excessive mentions or replies',
},
[ComAtprotoModerationDefs.REASONSEXUAL]: {
title: 'Unwanted Sexual Content',
description: 'Nudity or pornography not labeled as such',
},
__copyright__: {
title: 'Copyright Violation',
description: 'Contains copyrighted material',
},
...CommonReasons,
},
[CollectionId.List]: {
...CommonReasons,
[ComAtprotoModerationDefs.REASONVIOLATION]: {
title: 'Name or Description Violates Community Standards',
description: 'Terms used violate community standards',
},
},
}
const AccountReportReasons = {
[ComAtprotoModerationDefs.REASONMISLEADING]: {
title: 'Misleading Account',
description: 'Impersonation or false claims about identity or affiliation',
},
[ComAtprotoModerationDefs.REASONSPAM]: {
title: 'Frequently Posts Unwanted Content',
description: 'Spam; excessive mentions or replies',
},
[ComAtprotoModerationDefs.REASONVIOLATION]: {
title: 'Name or Description Violates Community Standards',
description: 'Terms used violate community standards',
},
}
const Option = ({
pal,
title,
description,
}: {
pal: UsePaletteValue
description: string
title: string
}) => {
return (
<View>
<Text style={pal.text} type="md-bold">
{title}
</Text>
<Text style={pal.textLight}>{description}</Text>
</View>
)
}
// This is mostly just content copy without almost any logic
// so this may grow over time and it makes sense to split it up into its own file
// to keep it separate from the actual reporting modal logic
const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) =>
useMemo(() => {
let items: ReasonMap = {...CommonReasons}
// If no atUri is passed, that means the reporting collection is account
if (!atUri) {
items = {...AccountReportReasons}
}
if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) {
items = {...CollectionToReasonsMap[atUri.collection]}
}
return Object.entries(items).map(([key, {title, description}]) => ({
key,
label: <Option pal={pal} title={title} description={description} />,
}))
}, [pal, atUri])
export const ReportReasonOptions = ({
atUri,
selectedIssue,
onSelectIssue,
}: {
atUri: AtUri | null
selectedIssue?: string
onSelectIssue: (key: string) => void
}) => {
const pal = usePalette('default')
const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri)
return (
<RadioGroup
items={ITEMS}
onSelect={onSelectIssue}
testID="reportReasonRadios"
initialSelection={selectedIssue}
/>
)
}

View file

@ -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,
},
})

View file

@ -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',
}

View file

@ -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}>

View file

@ -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)

View file

@ -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(

View file

@ -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}>
Muted
</Text>
</View>
)}
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
@ -312,15 +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}

View file

@ -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

View file

@ -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>
)

View file

@ -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}
/>
)
}

View file

@ -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},
})

View file

@ -217,7 +217,7 @@ function SuggestedFollow({
<UserAvatar
size={60}
avatar={profile.avatar}
moderation={moderation.avatar}
moderation={moderation.ui('avatar')}
/>
<View style={{width: '100%', paddingVertical: 12}}>
@ -227,7 +227,7 @@ function SuggestedFollow({
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.profile,
moderation.ui('displayName'),
)}
</Text>
<Text

View file

@ -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.`,
)

View file

@ -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}
/>
)

View file

@ -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)}&nbsp;
{sanitizeDisplayName(
displayName,
opts.moderation?.ui('displayName'),
)}
&nbsp;
<Text
type="md"
numberOfLines={1}

View file

@ -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,

View file

@ -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,
},
})

View file

@ -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`)}

View file

@ -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',
},
})

View file

@ -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>
)
}

View file

@ -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',
},
})

View file

@ -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,
},
})

View file

@ -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',
},
})

View file

@ -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,
},
})

View file

@ -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}
/>

View file

@ -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: {

View file

@ -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>
)
}

View 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>
)
}

View file

@ -1,304 +0,0 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {s} from 'lib/styles'
import {CenteredView} from '../com/util/Views'
import {ViewHeader} from '../com/util/ViewHeader'
import {Link, TextLink} from '../com/util/Link'
import {Text} from '../com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetMinimalShellMode} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ToggleButton} from '../com/util/forms/ToggleButton'
import {useSession} from '#/state/session'
import {
useProfileQuery,
useProfileUpdateMutation,
} from '#/state/queries/profile'
import {ScrollView} from '../com/util/Views'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
export function ModerationScreen({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics()
const {isTabletOrDesktop} = useWebMediaQueries()
const {openModal} = useModalControls()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
useFocusEffect(
React.useCallback(() => {
screen('Moderation')
setMinimalShellMode(false)
}, [screen, setMinimalShellMode]),
)
const onPressContentFiltering = React.useCallback(() => {
track('Moderation:ContentfilteringButtonClicked')
openModal({name: 'content-filtering-settings'})
}, [track, openModal])
return (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
]}
testID="moderationScreen">
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
<ScrollView contentContainerStyle={[styles.noBorder]}>
<View style={styles.spacer} />
<TouchableOpacity
testID="contentFilteringBtn"
style={[styles.linkCard, pal.view]}
onPress={onPressContentFiltering}
accessibilityRole="tab"
accessibilityHint=""
accessibilityLabel={_(msg`Open content filtering settings`)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="eye"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Content filtering</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="mutedWordsBtn"
style={[styles.linkCard, pal.view]}
onPress={() => mutedWordsDialogControl.open()}
accessibilityRole="tab"
accessibilityHint=""
accessibilityLabel={_(msg`Open muted words settings`)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="filter"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Muted words & tags</Trans>
</Text>
</TouchableOpacity>
<Link
testID="moderationlistsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/modlists">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="users-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Moderation lists</Trans>
</Text>
</Link>
<Link
testID="mutedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/muted-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="user-slash"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Muted accounts</Trans>
</Text>
</Link>
<Link
testID="blockedAccountsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/blocked-accounts">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="ban"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
<Trans>Blocked accounts</Trans>
</Text>
</Link>
<Text
type="xl-bold"
style={[
pal.text,
{
paddingHorizontal: 18,
paddingTop: 18,
paddingBottom: 6,
},
]}>
<Trans>Logged-out visibility</Trans>
</Text>
<PwiOptOut />
</ScrollView>
</CenteredView>
)
}
function PwiOptOut() {
const pal = usePalette('default')
const {_} = useLingui()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const updateProfile = useProfileUpdateMutation()
const isOptedOut =
profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
const canToggle = profile && !updateProfile.isPending
const onToggleOptOut = React.useCallback(() => {
if (!profile) {
return
}
let wasAdded = false
updateProfile.mutate({
profile,
updates: existing => {
// create labels attr if needed
existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
? existing.labels
: {
$type: 'com.atproto.label.defs#selfLabels',
values: [],
}
// toggle the label
const hasLabel = existing.labels.values.some(
l => l.val === '!no-unauthenticated',
)
if (hasLabel) {
wasAdded = false
existing.labels.values = existing.labels.values.filter(
l => l.val !== '!no-unauthenticated',
)
} else {
wasAdded = true
existing.labels.values.push({val: '!no-unauthenticated'})
}
// delete if no longer needed
if (existing.labels.values.length === 0) {
delete existing.labels
}
return existing
},
checkCommitted: res => {
const exists = !!res.data.labels?.some(
l => l.val === '!no-unauthenticated',
)
return exists === wasAdded
},
})
}, [updateProfile, profile])
return (
<View style={[pal.view, styles.toggleCard]}>
<View
style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}>
<ToggleButton
type="default-light"
label={_(
msg`Discourage apps from showing my account to logged-out users`,
)}
labelType="lg"
isSelected={isOptedOut}
onPress={canToggle ? onToggleOptOut : undefined}
style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]}
/>
{updateProfile.isPending && <ActivityIndicator />}
</View>
<View
style={{
flexDirection: 'column',
gap: 10,
paddingLeft: 66,
paddingRight: 12,
paddingBottom: 10,
marginBottom: 64,
}}>
<Text style={pal.textLight}>
<Trans>
Bluesky will not show your profile and posts to logged-out users.
Other apps may not honor this request. This does not make your
account private.
</Trans>
</Text>
<Text style={[pal.textLight, {fontWeight: '500'}]}>
<Trans>
Note: Bluesky is an open and public network. This setting only
limits the visibility of your content on the Bluesky app and
website, and other apps may not respect this setting. Your content
may still be shown to logged-out users by other apps and websites.
</Trans>
</Text>
<TextLink
style={pal.link}
href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"
text={_(msg`Learn more about what is public on Bluesky.`)}
/>
</View>
</View>
)
}
const styles = StyleSheet.create({
desktopContainer: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
spacer: {
height: 6,
},
linkCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
marginBottom: 1,
},
toggleCard: {
paddingVertical: 8,
paddingTop: 2,
paddingHorizontal: 6,
marginBottom: 1,
},
iconContainer: {
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 30,
marginRight: 12,
},
noBorder: {
borderBottomWidth: 0,
borderRightWidth: 0,
borderLeftWidth: 0,
borderTopWidth: 0,
},
})

View file

@ -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}))

View file

@ -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}

View file

@ -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'}

View file

@ -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])
@ -821,6 +825,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}

View file

@ -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>
)

View file

@ -67,6 +67,7 @@ export function Storybook() {
</Button>
</View>
<Dialogs />
<ThemeProvider theme="light">
<Theming />
</ThemeProvider>

View file

@ -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}>

View file

@ -101,8 +101,8 @@ function ShellInner() {
<Composer winHeight={winDim.height} />
<ModalsContainer />
<MutedWordsDialog />
<PortalOutlet />
<Lightbox />
<PortalOutlet />
</>
)
}

View file

@ -45,8 +45,9 @@ function ShellInner() {
<Composer winHeight={0} />
<ModalsContainer />
<MutedWordsDialog />
<PortalOutlet />
<Lightbox />
<PortalOutlet />
{!isDesktop && isDrawerOpen && (
<TouchableOpacity
onPress={() => setDrawerOpen(false)}