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:
parent
d5ebbeb3fc
commit
20d463ff2f
165 changed files with 7034 additions and 5009 deletions
560
src/screens/Moderation/index.tsx
Normal file
560
src/screens/Moderation/index.tsx
Normal file
|
@ -0,0 +1,560 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {LABELS} from '@atproto/api'
|
||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
useProfileQuery,
|
||||
useProfileUpdateMutation,
|
||||
} from '#/state/queries/profile'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
|
||||
import {
|
||||
UsePreferencesQueryResponse,
|
||||
useMyLabelersQuery,
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetAdultContentMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {logger} from '#/logger'
|
||||
import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {InlineLink, Link} from '#/components/Link'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as LabelingService from '#/components/LabelingServiceCard'
|
||||
import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
function ErrorState({error}: {error: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.p_xl]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_normal,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Hmmmm, it seems we're having trouble loading this data. See below for
|
||||
more details. If this issue persists, please contact us.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.rounded_md,
|
||||
a.mb_2xl,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.text_md, a.leading_normal]}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModerationScreen(
|
||||
_props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isLoading: isPreferencesLoading,
|
||||
error: preferencesError,
|
||||
data: preferences,
|
||||
} = usePreferencesQuery()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {height} = useSafeAreaFrame()
|
||||
|
||||
const isLoading = isPreferencesLoading
|
||||
const error = preferencesError
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
testID="moderationScreen"
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
{minHeight: height},
|
||||
...(gtMobile ? [a.border_l, a.border_r] : []),
|
||||
]}>
|
||||
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
|
||||
|
||||
{isLoading ? (
|
||||
<View style={[a.w_full, a.align_center, a.pt_2xl]}>
|
||||
<Loader size="xl" fill={t.atoms.text.color} />
|
||||
</View>
|
||||
) : error || !preferences ? (
|
||||
<ErrorState
|
||||
error={
|
||||
preferencesError?.toString() ||
|
||||
_(msg`Something went wrong, please try again.`)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ModerationScreenInner preferences={preferences} />
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function SubItem({
|
||||
title,
|
||||
icon: Icon,
|
||||
style,
|
||||
}: ViewStyleProp & {
|
||||
title: string
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.p_lg,
|
||||
a.gap_sm,
|
||||
style,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<Icon size="md" style={[t.atoms.text_contrast_medium]} />
|
||||
<Text style={[a.text_sm, a.font_bold]}>{title}</Text>
|
||||
</View>
|
||||
<ChevronRight
|
||||
size="sm"
|
||||
style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModerationScreenInner({
|
||||
preferences,
|
||||
}: {
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {screen} = useAnalytics()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
const birthdateDialogControl = Dialog.useDialogControl()
|
||||
const {
|
||||
isLoading: isLabelersLoading,
|
||||
data: labelers,
|
||||
error: labelersError,
|
||||
} = useMyLabelersQuery()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
screen('Moderation')
|
||||
setMinimalShellMode(false)
|
||||
}, [screen, setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
|
||||
usePreferencesSetAdultContentMutation()
|
||||
const adultContentEnabled = !!(
|
||||
(optimisticAdultContent && optimisticAdultContent.enabled) ||
|
||||
(!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
|
||||
)
|
||||
const ageNotSet = !preferences.userAge
|
||||
const isUnderage = (preferences.userAge || 0) < 18
|
||||
|
||||
const onToggleAdultContentEnabled = React.useCallback(
|
||||
async (selected: boolean) => {
|
||||
try {
|
||||
await setAdultContentPref({
|
||||
enabled: selected,
|
||||
})
|
||||
} catch (e: any) {
|
||||
logger.error(`Failed to set adult content pref`, {
|
||||
message: e.message,
|
||||
})
|
||||
}
|
||||
},
|
||||
[setAdultContentPref],
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.border_0,
|
||||
a.pt_2xl,
|
||||
a.px_lg,
|
||||
gtMobile && a.px_2xl,
|
||||
]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
|
||||
<Trans>Moderation tools</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Button
|
||||
testID="mutedWordsBtn"
|
||||
label={_(msg`Open muted words and tags settings`)}
|
||||
onPress={() => mutedWordsDialogControl.open()}>
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Muted words & tags`)}
|
||||
icon={Filter}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Divider />
|
||||
<Link testID="moderationlistsBtn" to="/moderation/modlists">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Moderation lists`)}
|
||||
icon={Group}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
<Divider />
|
||||
<Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Muted accounts`)}
|
||||
icon={Person}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
<Divider />
|
||||
<Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Blocked accounts`)}
|
||||
icon={CircleBanSign}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Content filters</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
{ageNotSet && (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Confirm your birthdate`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
birthdateDialogControl.open()
|
||||
}}
|
||||
style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
|
||||
<ButtonText>
|
||||
<Trans>Confirm your age:</Trans>
|
||||
</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Set birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<BirthDateSettingsDialog
|
||||
control={birthdateDialogControl}
|
||||
preferences={preferences}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{!ageNotSet && !isUnderage && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.py_lg,
|
||||
a.px_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}>
|
||||
<Text style={[a.font_semibold, t.atoms.text_contrast_high]}>
|
||||
<Trans>Enable adult content</Trans>
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Toggle to enable or disable adult content`)}
|
||||
name="adultContent"
|
||||
value={adultContentEnabled}
|
||||
onChange={onToggleAdultContentEnabled}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{adultContentEnabled ? (
|
||||
<Trans>Enabled</Trans>
|
||||
) : (
|
||||
<Trans>Disabled</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Toggle.Switch />
|
||||
</View>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{!isUnderage && adultContentEnabled && (
|
||||
<>
|
||||
<GlobalModerationLabelPref labelValueDefinition={LABELS.porn} />
|
||||
<Divider />
|
||||
<GlobalModerationLabelPref
|
||||
labelValueDefinition={LABELS.sexual}
|
||||
/>
|
||||
<Divider />
|
||||
<GlobalModerationLabelPref
|
||||
labelValueDefinition={LABELS['graphic-media']}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<GlobalModerationLabelPref labelValueDefinition={LABELS.nudity} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Advanced</Trans>
|
||||
</Text>
|
||||
|
||||
{isLabelersLoading ? (
|
||||
<Loader />
|
||||
) : labelersError || !labelers ? (
|
||||
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text>
|
||||
<Trans>
|
||||
We were unable to load your configured labelers at this time.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||
{labelers.map((labeler, i) => {
|
||||
return (
|
||||
<React.Fragment key={labeler.creator.did}>
|
||||
{i !== 0 && <Divider />}
|
||||
<LabelingService.Link labeler={labeler}>
|
||||
{state => (
|
||||
<LabelingService.Outer
|
||||
style={[
|
||||
i === 0 && {
|
||||
borderTopLeftRadius: a.rounded_sm.borderRadius,
|
||||
borderTopRightRadius: a.rounded_sm.borderRadius,
|
||||
},
|
||||
i === labelers.length - 1 && {
|
||||
borderBottomLeftRadius: a.rounded_sm.borderRadius,
|
||||
borderBottomRightRadius: a.rounded_sm.borderRadius,
|
||||
},
|
||||
(state.hovered || state.pressed) && [
|
||||
t.atoms.bg_contrast_50,
|
||||
],
|
||||
]}>
|
||||
<LabelingService.Avatar />
|
||||
<LabelingService.Content>
|
||||
<LabelingService.Title
|
||||
value={getLabelingServiceTitle({
|
||||
displayName: labeler.creator.displayName,
|
||||
handle: labeler.creator.handle,
|
||||
})}
|
||||
/>
|
||||
<LabelingService.Description
|
||||
value={labeler.creator.description}
|
||||
handle={labeler.creator.handle}
|
||||
/>
|
||||
</LabelingService.Content>
|
||||
</LabelingService.Outer>
|
||||
)}
|
||||
</LabelingService.Link>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Logged-out visibility</Trans>
|
||||
</Text>
|
||||
|
||||
<PwiOptOut />
|
||||
|
||||
<View style={{height: 200}} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PwiOptOut() {
|
||||
const t = useTheme()
|
||||
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={[a.pt_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
|
||||
<Toggle.Item
|
||||
disabled={!canToggle}
|
||||
value={isOptedOut}
|
||||
onChange={onToggleOptOut}
|
||||
name="logged_out_visibility"
|
||||
label={_(
|
||||
msg`Discourage apps from showing my account to logged-out users`,
|
||||
)}>
|
||||
<Toggle.Switch />
|
||||
<Toggle.Label style={[a.text_md]}>
|
||||
<Trans>
|
||||
Discourage apps from showing my account to logged-out users
|
||||
</Trans>
|
||||
</Toggle.Label>
|
||||
</Toggle.Item>
|
||||
|
||||
{updateProfile.isPending && <Loader />}
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}>
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
<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={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
<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>
|
||||
|
||||
<InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
|
||||
<Trans>Learn more about what is public on Bluesky.</Trans>
|
||||
</InlineLink>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -56,7 +56,9 @@ export function AdultContentEnabledPref({
|
|||
|
||||
try {
|
||||
mutate({
|
||||
enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
|
||||
enabled: !(
|
||||
variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled
|
||||
),
|
||||
})
|
||||
} catch (e) {
|
||||
Toast.show(
|
||||
|
@ -75,7 +77,10 @@ export function AdultContentEnabledPref({
|
|||
<Toggle.Item
|
||||
name={_(msg`Enable adult content in your feeds`)}
|
||||
label={_(msg`Enable adult content in your feeds`)}
|
||||
value={variables?.enabled ?? preferences?.adultContentEnabled}
|
||||
value={
|
||||
variables?.enabled ??
|
||||
preferences?.moderationPrefs.adultContentEnabled
|
||||
}
|
||||
onChange={onToggleAdultContent}>
|
||||
<View
|
||||
style={[
|
||||
|
|
|
@ -1,40 +1,51 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {LabelPreference} from '@atproto/api'
|
||||
import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
CONFIGURABLE_LABEL_GROUPS,
|
||||
ConfigurableLabelGroup,
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
|
||||
export function ModerationOption({
|
||||
labelGroup,
|
||||
isMounted,
|
||||
labelValueDefinition,
|
||||
disabled,
|
||||
}: {
|
||||
labelGroup: ConfigurableLabelGroup
|
||||
isMounted: React.MutableRefObject<boolean>
|
||||
labelValueDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const label = labelValueDefinition.identifier
|
||||
const visibility =
|
||||
variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
|
||||
variables?.visibility ?? preferences?.moderationPrefs.labels?.[label]
|
||||
|
||||
const allLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings =
|
||||
labelValueDefinition.identifier in allLabelStrings
|
||||
? allLabelStrings[labelValueDefinition.identifier]
|
||||
: {
|
||||
name: labelValueDefinition.identifier,
|
||||
description: `Labeled "${labelValueDefinition.identifier}"`,
|
||||
}
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(vis: string[]) => {
|
||||
mutate({labelGroup, visibility: vis[0] as LabelPreference})
|
||||
mutate({
|
||||
label,
|
||||
visibility: vis[0] as LabelPreference,
|
||||
labelerDid: undefined,
|
||||
})
|
||||
},
|
||||
[mutate, labelGroup],
|
||||
[mutate, label],
|
||||
)
|
||||
|
||||
const labels = {
|
||||
|
@ -44,7 +55,7 @@ export function ModerationOption({
|
|||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
|
@ -52,33 +63,37 @@ export function ModerationOption({
|
|||
a.py_xs,
|
||||
a.px_xs,
|
||||
a.align_center,
|
||||
]}
|
||||
layout={Layout.easing(Easing.ease).duration(200)}
|
||||
entering={isMounted.current ? FadeIn : undefined}>
|
||||
<View style={[a.gap_xs, {width: '50%'}]}>
|
||||
<Text style={[a.font_bold]}>{groupInfo.title}</Text>
|
||||
]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{groupInfo.subtitle}
|
||||
{labelStrings.description}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.justify_center, {minHeight: 35}]}>
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
|
||||
)}
|
||||
values={[visibility ?? 'hide']}
|
||||
onChange={onChange}>
|
||||
<ToggleButton.Button name="hide" label={labels.hide}>
|
||||
{labels.hide}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="warn" label={labels.warn}>
|
||||
{labels.warn}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="ignore" label={labels.show}>
|
||||
{labels.show}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
<View style={[a.justify_center, {minHeight: 40}]}>
|
||||
{disabled ? (
|
||||
<Text style={[a.font_bold]}>
|
||||
<Trans>Hide</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||
)}
|
||||
values={[visibility ?? 'hide']}
|
||||
onChange={onChange}>
|
||||
<ToggleButton.Button name="ignore" label={labels.show}>
|
||||
{labels.show}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="warn" label={labels.warn}>
|
||||
{labels.warn}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="hide" label={labels.hide}>
|
||||
{labels.hide}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,15 +2,10 @@ import React from 'react'
|
|||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import Animated, {Easing, Layout} from 'react-native-reanimated'
|
||||
import {LABELS} from '@atproto/api'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
configurableAdultLabelGroups,
|
||||
configurableOtherLabelGroups,
|
||||
usePreferencesSetAdultContentMutation,
|
||||
} from 'state/queries/preferences'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
|
@ -28,14 +23,6 @@ import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/Adult
|
|||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
function AnimatedDivider() {
|
||||
return (
|
||||
<Animated.View layout={Layout.easing(Easing.ease).duration(200)}>
|
||||
<Divider />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepModeration() {
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
|
@ -52,7 +39,7 @@ export function StepModeration() {
|
|||
|
||||
const adultContentEnabled = !!(
|
||||
(variables && variables.enabled) ||
|
||||
(!variables && preferences?.adultContentEnabled)
|
||||
(!variables && preferences?.moderationPrefs.adultContentEnabled)
|
||||
)
|
||||
|
||||
const onContinue = React.useCallback(() => {
|
||||
|
@ -86,22 +73,19 @@ export function StepModeration() {
|
|||
<AdultContentEnabledPref mutate={mutate} variables={variables} />
|
||||
|
||||
<View style={[a.gap_sm, a.w_full]}>
|
||||
{adultContentEnabled &&
|
||||
configurableAdultLabelGroups.map((g, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index === 0 && <AnimatedDivider />}
|
||||
<ModerationOption labelGroup={g} isMounted={isMounted} />
|
||||
<AnimatedDivider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{configurableOtherLabelGroups.map((g, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{!adultContentEnabled && index === 0 && <AnimatedDivider />}
|
||||
<ModerationOption labelGroup={g} isMounted={isMounted} />
|
||||
<AnimatedDivider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<ModerationOption
|
||||
labelValueDefinition={LABELS.porn}
|
||||
disabled={!adultContentEnabled}
|
||||
/>
|
||||
<ModerationOption
|
||||
labelValueDefinition={LABELS.sexual}
|
||||
disabled={!adultContentEnabled}
|
||||
/>
|
||||
<ModerationOption
|
||||
labelValueDefinition={LABELS['graphic-media']}
|
||||
disabled={!adultContentEnabled}
|
||||
/>
|
||||
<ModerationOption labelValueDefinition={LABELS.nudity} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -88,7 +88,7 @@ export function SuggestedAccountCard({
|
|||
<UserAvatar
|
||||
size={48}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
|
|
|
@ -76,7 +76,7 @@ export function StepSuggestedAccounts() {
|
|||
return aggregateInterestItems(
|
||||
state.interestsStepResults.selectedInterests,
|
||||
state.interestsStepResults.apiResponse.suggestedAccountDids,
|
||||
state.interestsStepResults.apiResponse.suggestedAccountDids.default,
|
||||
state.interestsStepResults.apiResponse.suggestedAccountDids.default || [],
|
||||
)
|
||||
}, [state.interestsStepResults])
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
|
|
@ -21,7 +21,7 @@ import {
|
|||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {IS_PROD_SERVICE} from 'lib/constants'
|
||||
import {IS_TEST_USER} from 'lib/constants'
|
||||
import {useSession} from 'state/session'
|
||||
|
||||
export function StepTopicalFeeds() {
|
||||
|
@ -32,14 +32,14 @@ export function StepTopicalFeeds() {
|
|||
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const suggestedFeedUris = React.useMemo(() => {
|
||||
if (!IS_PROD_SERVICE(currentAccount?.service)) return []
|
||||
if (IS_TEST_USER(currentAccount?.handle)) return []
|
||||
return aggregateInterestItems(
|
||||
state.interestsStepResults.selectedInterests,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris.default,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris.default || [],
|
||||
).slice(0, 10)
|
||||
}, [
|
||||
currentAccount?.service,
|
||||
currentAccount?.handle,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||
state.interestsStepResults.selectedInterests,
|
||||
])
|
||||
|
|
72
src/screens/Profile/ErrorState.tsx
Normal file
72
src/screens/Profile/ErrorState.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
|
||||
export function ErrorState({error}: {error: string}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View style={[a.px_xl]}>
|
||||
<CircleInfo width={48} style={[t.atoms.text_contrast_low]} />
|
||||
|
||||
<Text style={[a.text_xl, a.font_bold, a.pb_md, a.pt_xl]}>
|
||||
<Trans>Hmmmm, we couldn't load that moderation service.</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_normal,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
This moderation service is unavailable. See below for more details. If
|
||||
this issue persists, contact us.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.rounded_md,
|
||||
a.mb_2xl,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.text_md, a.leading_normal]}>{error}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row'}}>
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
label={_(msg`Go Back`)}
|
||||
accessibilityHint="Return to previous page"
|
||||
onPress={onPressBack}>
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
31
src/screens/Profile/Header/DisplayName.tsx
Normal file
31
src/screens/Profile/Header/DisplayName.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ProfileHeaderDisplayName({
|
||||
profile,
|
||||
moderation,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
moderation: ModerationDecision
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View pointerEvents="none">
|
||||
<Text
|
||||
testID="profileHeaderDisplayName"
|
||||
style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
46
src/screens/Profile/Header/Handle.tsx
Normal file
46
src/screens/Profile/Header/Handle.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ProfileHeaderHandle({
|
||||
profile,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const invalidHandle = isInvalidHandle(profile.handle)
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none">
|
||||
{profile.viewer?.followedBy && !blockHide ? (
|
||||
<View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
|
||||
<Text style={[t.atoms.text, a.text_sm]}>
|
||||
<Trans>Follows you</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
<Text
|
||||
style={[
|
||||
invalidHandle
|
||||
? [
|
||||
a.border,
|
||||
a.text_xs,
|
||||
a.px_sm,
|
||||
a.py_xs,
|
||||
a.rounded_xs,
|
||||
{borderColor: t.palette.contrast_200},
|
||||
]
|
||||
: [a.text_md, t.atoms.text_contrast_medium],
|
||||
web({wordBreak: 'break-all'}),
|
||||
]}>
|
||||
{invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
61
src/screens/Profile/Header/Metrics.tsx
Normal file
61
src/screens/Profile/Header/Metrics.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
|
||||
export function ProfileHeaderMetrics({
|
||||
profile,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]}
|
||||
pointerEvents="box-none">
|
||||
<InlineLink
|
||||
testID="profileHeaderFollowersButton"
|
||||
style={[a.flex_row, t.atoms.text]}
|
||||
to={makeProfileLink(profile, 'followers')}
|
||||
label={`${followers} ${pluralizedFollowers}`}>
|
||||
<Text style={[a.font_bold, a.text_md]}>{followers} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||
{pluralizedFollowers}
|
||||
</Text>
|
||||
</InlineLink>
|
||||
<InlineLink
|
||||
testID="profileHeaderFollowsButton"
|
||||
style={[a.flex_row, t.atoms.text]}
|
||||
to={makeProfileLink(profile, 'follows')}
|
||||
label={_(msg`${following} following`)}>
|
||||
<Trans>
|
||||
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||
following
|
||||
</Text>
|
||||
</Trans>
|
||||
</InlineLink>
|
||||
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
|
||||
{formatCount(profile.postsCount || 0)}{' '}
|
||||
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
|
||||
{pluralize(profile.postsCount || 0, 'post')}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
329
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
Normal file
329
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
Normal file
|
@ -0,0 +1,329 @@
|
|||
import React, {memo, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyLabelerDefs,
|
||||
ModerationOpts,
|
||||
moderateProfile,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||
import {logger} from '#/logger'
|
||||
import {Haptics} from '#/lib/haptics'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {isAppLabeler} from '#/lib/moderation'
|
||||
|
||||
import {atoms as a, useTheme, tokens} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ProfileHeaderShell} from './Shell'
|
||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||
import {ProfileHeaderHandle} from './Handle'
|
||||
import {ProfileHeaderMetrics} from './Metrics'
|
||||
import {
|
||||
Heart2_Stroke2_Corner0_Rounded as Heart,
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||
} from '#/components/icons/Heart2'
|
||||
import {DialogOuterProps} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Link} from '#/components/Link'
|
||||
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
descriptionRT: RichTextAPI | null
|
||||
moderationOpts: ModerationOpts
|
||||
hideBackButton?: boolean
|
||||
isPlaceholderProfile?: boolean
|
||||
}
|
||||
|
||||
let ProfileHeaderLabeler = ({
|
||||
profile: profileUnshadowed,
|
||||
labeler,
|
||||
descriptionRT,
|
||||
moderationOpts,
|
||||
hideBackButton = false,
|
||||
isPlaceholderProfile,
|
||||
}: Props): React.ReactNode => {
|
||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const {track} = useAnalytics()
|
||||
const cantSubscribePrompt = Prompt.usePromptControl()
|
||||
const isSelf = currentAccount?.did === profile.did
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync: toggleSubscription, variables} =
|
||||
useLabelerSubscriptionMutation()
|
||||
const isSubscribed =
|
||||
variables?.subscribe ??
|
||||
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
|
||||
const canSubscribe =
|
||||
isSubscribed ||
|
||||
(preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
|
||||
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
|
||||
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
|
||||
useUnlikeMutation()
|
||||
const [likeUri, setLikeUri] = React.useState<string>(
|
||||
labeler.viewer?.like || '',
|
||||
)
|
||||
const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0)
|
||||
|
||||
const onToggleLiked = React.useCallback(async () => {
|
||||
if (!labeler) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
Haptics.default()
|
||||
|
||||
if (likeUri) {
|
||||
await unlikeMod({uri: likeUri})
|
||||
track('CustomFeed:Unlike')
|
||||
setLikeCount(c => c - 1)
|
||||
setLikeUri('')
|
||||
} else {
|
||||
const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
|
||||
track('CustomFeed:Like')
|
||||
setLikeCount(c => c + 1)
|
||||
setLikeUri(res.uri)
|
||||
}
|
||||
} catch (e: any) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was an an issue contacting the server, please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
logger.error(`Failed to toggle labeler like`, {message: e.message})
|
||||
}
|
||||
}, [labeler, likeUri, likeMod, unlikeMod, track, _])
|
||||
|
||||
const onPressEditProfile = React.useCallback(() => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
openModal({
|
||||
name: 'edit-profile',
|
||||
profile,
|
||||
})
|
||||
}, [track, openModal, profile])
|
||||
|
||||
const onPressSubscribe = React.useCallback(async () => {
|
||||
if (!canSubscribe) {
|
||||
cantSubscribePrompt.open()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await toggleSubscription({
|
||||
did: profile.did,
|
||||
subscribe: !isSubscribed,
|
||||
})
|
||||
} catch (e: any) {
|
||||
// setSubscriptionError(e.message)
|
||||
logger.error(`Failed to subscribe to labeler`, {message: e.message})
|
||||
}
|
||||
}, [
|
||||
toggleSubscription,
|
||||
isSubscribed,
|
||||
profile,
|
||||
canSubscribe,
|
||||
cantSubscribePrompt,
|
||||
])
|
||||
|
||||
const isMe = React.useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
[currentAccount, profile],
|
||||
)
|
||||
|
||||
return (
|
||||
<ProfileHeaderShell
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
|
||||
<View
|
||||
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
|
||||
pointerEvents="box-none">
|
||||
{isMe ? (
|
||||
<Button
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={onPressEditProfile}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={a.rounded_full}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : !isAppLabeler(profile.did) ? (
|
||||
<>
|
||||
<Button
|
||||
testID="toggleSubscribeBtn"
|
||||
label={
|
||||
isSubscribed
|
||||
? _(msg`Unsubscribe from this labeler`)
|
||||
: _(msg`Subscribe to this labeler`)
|
||||
}
|
||||
disabled={!hasSession}
|
||||
onPress={onPressSubscribe}>
|
||||
{state => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 12,
|
||||
backgroundColor:
|
||||
isSubscribed || !canSubscribe
|
||||
? state.hovered || state.pressed
|
||||
? t.palette.contrast_50
|
||||
: t.palette.contrast_25
|
||||
: state.hovered || state.pressed
|
||||
? tokens.color.temp_purple_dark
|
||||
: tokens.color.temp_purple,
|
||||
},
|
||||
a.px_lg,
|
||||
a.rounded_sm,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{
|
||||
color: canSubscribe
|
||||
? isSubscribed
|
||||
? t.palette.contrast_700
|
||||
: t.palette.white
|
||||
: t.palette.contrast_400,
|
||||
},
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
]}>
|
||||
{isSubscribed ? (
|
||||
<Trans>Unsubscribe</Trans>
|
||||
) : (
|
||||
<Trans>Subscribe to Labeler</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<ProfileMenu profile={profile} />
|
||||
</View>
|
||||
<View style={[a.flex_col, a.gap_xs, a.pb_md]}>
|
||||
<ProfileHeaderDisplayName profile={profile} moderation={moderation} />
|
||||
<ProfileHeaderHandle profile={profile} />
|
||||
</View>
|
||||
{!isPlaceholderProfile && (
|
||||
<>
|
||||
{isSelf && <ProfileHeaderMetrics profile={profile} />}
|
||||
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||
<View pointerEvents="auto">
|
||||
<RichText
|
||||
testID="profileHeaderDescription"
|
||||
style={[a.text_md]}
|
||||
numberOfLines={15}
|
||||
value={descriptionRT}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
{!isAppLabeler(profile.did) && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}>
|
||||
<Button
|
||||
testID="toggleLikeBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Like this feed`)}
|
||||
disabled={!hasSession || isLikePending || isUnlikePending}
|
||||
onPress={onToggleLiked}>
|
||||
{likeUri ? (
|
||||
<HeartFilled fill={t.palette.negative_400} />
|
||||
) : (
|
||||
<Heart fill={t.atoms.text_contrast_medium.color} />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{typeof likeCount === 'number' && (
|
||||
<Link
|
||||
to={{
|
||||
screen: 'ProfileLabelerLikedBy',
|
||||
params: {
|
||||
name: labeler.creator.handle || labeler.creator.did,
|
||||
},
|
||||
}}
|
||||
size="tiny"
|
||||
label={_(
|
||||
msg`Liked by ${likeCount} ${pluralize(
|
||||
likeCount,
|
||||
'user',
|
||||
)}`,
|
||||
)}>
|
||||
{({hovered, focused, pressed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
(hovered || focused || pressed) &&
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>
|
||||
Liked by {likeCount} {pluralize(likeCount, 'user')}
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<CantSubscribePrompt control={cantSubscribePrompt} />
|
||||
</ProfileHeaderShell>
|
||||
)
|
||||
}
|
||||
ProfileHeaderLabeler = memo(ProfileHeaderLabeler)
|
||||
export {ProfileHeaderLabeler}
|
||||
|
||||
function CantSubscribePrompt({
|
||||
control,
|
||||
}: {
|
||||
control: DialogOuterProps['control']
|
||||
}) {
|
||||
return (
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.Title>Unable to subscribe</Prompt.Title>
|
||||
<Prompt.Description>
|
||||
<Trans>
|
||||
We're sorry! You can only subscribe to ten labelers, and you've
|
||||
reached your limit of ten.
|
||||
</Trans>
|
||||
</Prompt.Description>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action onPress={control.close}>OK</Prompt.Action>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
286
src/screens/Profile/Header/ProfileHeaderStandard.tsx
Normal file
286
src/screens/Profile/Header/ProfileHeaderStandard.tsx
Normal file
|
@ -0,0 +1,286 @@
|
|||
import React, {memo, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ModerationOpts,
|
||||
moderateProfile,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useSession, useRequireAuth} from '#/state/session'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {
|
||||
useProfileFollowMutationQueue,
|
||||
useProfileBlockMutationQueue,
|
||||
} from '#/state/queries/profile'
|
||||
import {logger} from '#/logger'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ProfileHeaderShell} from './Shell'
|
||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||
import {ProfileHeaderHandle} from './Handle'
|
||||
import {ProfileHeaderMetrics} from './Metrics'
|
||||
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
descriptionRT: RichTextAPI | null
|
||||
moderationOpts: ModerationOpts
|
||||
hideBackButton?: boolean
|
||||
isPlaceholderProfile?: boolean
|
||||
}
|
||||
|
||||
let ProfileHeaderStandard = ({
|
||||
profile: profileUnshadowed,
|
||||
descriptionRT,
|
||||
moderationOpts,
|
||||
hideBackButton = false,
|
||||
isPlaceholderProfile,
|
||||
}: Props): React.ReactNode => {
|
||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const t = useTheme()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const {track} = useAnalytics()
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
'ProfileHeader',
|
||||
)
|
||||
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
const unblockPromptControl = Prompt.usePromptControl()
|
||||
const requireAuth = useRequireAuth()
|
||||
|
||||
const onPressEditProfile = React.useCallback(() => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
openModal({
|
||||
name: 'edit-profile',
|
||||
profile,
|
||||
})
|
||||
}, [track, openModal, profile])
|
||||
|
||||
const onPressFollow = () => {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
track('ProfileHeader:FollowButtonClicked')
|
||||
await queueFollow()
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Following ${sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)}`,
|
||||
),
|
||||
)
|
||||
} 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,
|
||||
moderation.ui('displayName'),
|
||||
)}`,
|
||||
),
|
||||
)
|
||||
} 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 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],
|
||||
)
|
||||
|
||||
return (
|
||||
<ProfileHeaderShell
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
|
||||
<View
|
||||
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]}
|
||||
pointerEvents="box-none">
|
||||
{isMe ? (
|
||||
<Button
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={onPressEditProfile}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={a.rounded_full}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : profile.viewer?.blocking ? (
|
||||
profile.viewer?.blockingByList ? null : (
|
||||
<Button
|
||||
testID="unblockBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
label={_(msg`Unblock`)}
|
||||
disabled={!hasSession}
|
||||
onPress={() => unblockPromptControl.open()}
|
||||
style={a.rounded_full}>
|
||||
<ButtonText>
|
||||
<Trans context="action">Unblock</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
) : !profile.viewer?.blockedBy ? (
|
||||
<>
|
||||
{hasSession && (
|
||||
<Button
|
||||
testID="suggestedFollowsBtn"
|
||||
size="small"
|
||||
color={showSuggestedFollows ? 'primary' : 'secondary'}
|
||||
variant="solid"
|
||||
shape="round"
|
||||
onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
|
||||
label={_(msg`Show follows similar to ${profile.handle}`)}>
|
||||
<FontAwesomeIcon
|
||||
icon="user-plus"
|
||||
style={
|
||||
showSuggestedFollows
|
||||
? {color: t.palette.white}
|
||||
: t.atoms.text
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
||||
size="small"
|
||||
color={profile.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profile.viewer?.following
|
||||
? _(msg`Unfollow ${profile.handle}`)
|
||||
: _(msg`Follow ${profile.handle}`)
|
||||
}
|
||||
disabled={!hasSession}
|
||||
onPress={
|
||||
profile.viewer?.following ? onPressUnfollow : onPressFollow
|
||||
}
|
||||
style={[a.rounded_full, a.gap_xs]}>
|
||||
<ButtonIcon
|
||||
position="left"
|
||||
icon={profile.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profile.viewer?.following ? (
|
||||
<Trans>Following</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<ProfileMenu profile={profile} />
|
||||
</View>
|
||||
<View style={[a.flex_col, a.gap_xs, a.pb_sm]}>
|
||||
<ProfileHeaderDisplayName profile={profile} moderation={moderation} />
|
||||
<ProfileHeaderHandle profile={profile} />
|
||||
</View>
|
||||
{!isPlaceholderProfile && (
|
||||
<>
|
||||
<ProfileHeaderMetrics profile={profile} />
|
||||
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||
<View pointerEvents="auto">
|
||||
<RichText
|
||||
testID="profileHeaderDescription"
|
||||
style={[a.text_md]}
|
||||
numberOfLines={15}
|
||||
value={descriptionRT}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
{showSuggestedFollows && (
|
||||
<ProfileHeaderSuggestedFollows
|
||||
actorDid={profile.did}
|
||||
requestDismiss={() => {
|
||||
if (showSuggestedFollows) {
|
||||
setShowSuggestedFollows(false)
|
||||
} else {
|
||||
track('ProfileHeader:SuggestedFollowsOpened')
|
||||
setShowSuggestedFollows(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
/>
|
||||
</ProfileHeaderShell>
|
||||
)
|
||||
}
|
||||
ProfileHeaderStandard = memo(ProfileHeaderStandard)
|
||||
export {ProfileHeaderStandard}
|
164
src/screens/Profile/Header/Shell.tsx
Normal file
164
src/screens/Profile/Header/Shell.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
import React, {memo} from 'react'
|
||||
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {BACK_HITSLOP} from 'lib/constants'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
|
||||
import {BlurView} from 'view/com/util/BlurView'
|
||||
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {UserBanner} from 'view/com/util/UserBanner'
|
||||
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
|
||||
|
||||
interface Props {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
moderation: ModerationDecision
|
||||
hideBackButton?: boolean
|
||||
isPlaceholderProfile?: boolean
|
||||
}
|
||||
|
||||
let ProfileHeaderShell = ({
|
||||
children,
|
||||
profile,
|
||||
moderation,
|
||||
hideBackButton = false,
|
||||
isPlaceholderProfile,
|
||||
}: React.PropsWithChildren<Props>): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {openLightbox} = useLightboxControls()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
const onPressAvi = React.useCallback(() => {
|
||||
const modui = moderation.ui('avatar')
|
||||
if (profile.avatar && !(modui.blur && modui.noOverride)) {
|
||||
openLightbox(new ProfileImageLightbox(profile))
|
||||
}
|
||||
}, [openLightbox, profile, moderation])
|
||||
|
||||
const isMe = React.useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
[currentAccount, profile],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={t.atoms.bg} pointerEvents="box-none">
|
||||
<View pointerEvents="none">
|
||||
{isPlaceholderProfile ? (
|
||||
<LoadingPlaceholder
|
||||
width="100%"
|
||||
height={150}
|
||||
style={{borderRadius: 0}}
|
||||
/>
|
||||
) : (
|
||||
<UserBanner
|
||||
type={profile.associated?.labeler ? 'labeler' : 'default'}
|
||||
banner={profile.banner}
|
||||
moderation={moderation.ui('banner')}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{children}
|
||||
|
||||
<View style={[a.px_lg, a.pb_sm]} pointerEvents="box-none">
|
||||
<ProfileHeaderAlerts moderation={moderation} />
|
||||
{isMe && (
|
||||
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!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" color="white" />
|
||||
</BlurView>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
)}
|
||||
<TouchableWithoutFeedback
|
||||
testID="profileHeaderAviButton"
|
||||
onPress={onPressAvi}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
|
||||
accessibilityHint="">
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
{borderColor: t.atoms.bg.backgroundColor},
|
||||
styles.avi,
|
||||
profile.associated?.labeler && styles.aviLabeler,
|
||||
]}>
|
||||
<UserAvatar
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
size={90}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
ProfileHeaderShell = memo(ProfileHeaderShell)
|
||||
export {ProfileHeaderShell}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
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: 94,
|
||||
height: 94,
|
||||
borderRadius: 47,
|
||||
borderWidth: 2,
|
||||
},
|
||||
aviLabeler: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
})
|
78
src/screens/Profile/Header/index.tsx
Normal file
78
src/screens/Profile/Header/index.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import React, {memo} from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyLabelerDefs,
|
||||
ModerationOpts,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
import {ProfileHeaderStandard} from './ProfileHeaderStandard'
|
||||
import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
|
||||
|
||||
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
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
|
||||
descriptionRT: RichTextAPI | null
|
||||
moderationOpts: ModerationOpts
|
||||
hideBackButton?: boolean
|
||||
isPlaceholderProfile?: boolean
|
||||
}
|
||||
|
||||
let ProfileHeader = (props: Props): React.ReactNode => {
|
||||
if (props.profile.associated?.labeler) {
|
||||
if (!props.labeler) {
|
||||
return <ProfileHeaderLoading />
|
||||
}
|
||||
return <ProfileHeaderLabeler {...props} labeler={props.labeler} />
|
||||
}
|
||||
return <ProfileHeaderStandard {...props} />
|
||||
}
|
||||
ProfileHeader = memo(ProfileHeader)
|
||||
export {ProfileHeader}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
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,
|
||||
},
|
||||
br40: {borderRadius: 40},
|
||||
br50: {borderRadius: 50},
|
||||
})
|
46
src/screens/Profile/ProfileLabelerLikedBy.tsx
Normal file
46
src/screens/Profile/ProfileLabelerLikedBy.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {LikedByList} from '#/components/LikedByList'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
|
||||
export function ProfileLabelerLikedByScreen({
|
||||
route,
|
||||
}: NativeStackScreenProps<CommonNavigatorParams, 'ProfileLabelerLikedBy'>) {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {name: handleOrDid} = route.params
|
||||
const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self')
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.w_full,
|
||||
a.h_full_vh,
|
||||
gtMobile && [
|
||||
{
|
||||
maxWidth: 600,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<ViewHeader title={_(msg`Liked By`)} />
|
||||
<LikedByList uri={uri} />
|
||||
</View>
|
||||
)
|
||||
}
|
88
src/screens/Profile/Sections/Feed.tsx
Normal file
88
src/screens/Profile/Sections/Feed.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ListRef} from 'view/com/util/List'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
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 {SectionRef} from './types'
|
||||
|
||||
interface FeedSectionProps {
|
||||
feed: FeedDescriptor
|
||||
headerHeight: number
|
||||
isFocused: boolean
|
||||
scrollElRef: ListRef
|
||||
ignoreFilterFor?: string
|
||||
}
|
||||
export const ProfileFeedSection = 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>
|
||||
)
|
||||
}
|
233
src/screens/Profile/Sections/Labels.tsx
Normal file
233
src/screens/Profile/Sections/Labels.tsx
Normal file
|
@ -0,0 +1,233 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
AppBskyLabelerDefs,
|
||||
ModerationOpts,
|
||||
interpretLabelValueDefinitions,
|
||||
InterpretedLabelValueDefinition,
|
||||
} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||
|
||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
|
||||
import {ListRef} from '#/view/com/util/List'
|
||||
import {SectionRef} from './types'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {CenteredView, ScrollView} from '#/view/com/util/Views'
|
||||
import {ErrorState} from '../ErrorState'
|
||||
import {ModerationLabelPref} from '#/components/moderation/ModerationLabelPref'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
|
||||
interface LabelsSectionProps {
|
||||
isLabelerLoading: boolean
|
||||
labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined
|
||||
labelerError: Error | null
|
||||
moderationOpts: ModerationOpts
|
||||
scrollElRef: ListRef
|
||||
headerHeight: number
|
||||
}
|
||||
export const ProfileLabelsSection = React.forwardRef<
|
||||
SectionRef,
|
||||
LabelsSectionProps
|
||||
>(function LabelsSectionImpl(
|
||||
{
|
||||
isLabelerLoading,
|
||||
labelerInfo,
|
||||
labelerError,
|
||||
moderationOpts,
|
||||
scrollElRef,
|
||||
headerHeight,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {height: minHeight} = useSafeAreaFrame()
|
||||
|
||||
const onScrollToTop = React.useCallback(() => {
|
||||
// @ts-ignore TODO fix this
|
||||
scrollElRef.current?.scrollTo({
|
||||
animated: isNative,
|
||||
x: 0,
|
||||
y: -headerHeight,
|
||||
})
|
||||
}, [scrollElRef, headerHeight])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
return (
|
||||
<CenteredView>
|
||||
<View
|
||||
style={[
|
||||
a.border_l,
|
||||
a.border_r,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
minHeight,
|
||||
},
|
||||
]}>
|
||||
{isLabelerLoading ? (
|
||||
<View style={[a.w_full, a.align_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : labelerError || !labelerInfo ? (
|
||||
<ErrorState
|
||||
error={
|
||||
labelerError?.toString() ||
|
||||
_(msg`Something went wrong, please try again.`)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ProfileLabelsSectionInner
|
||||
moderationOpts={moderationOpts}
|
||||
labelerInfo={labelerInfo}
|
||||
scrollElRef={scrollElRef}
|
||||
headerHeight={headerHeight}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
})
|
||||
|
||||
export function ProfileLabelsSectionInner({
|
||||
moderationOpts,
|
||||
labelerInfo,
|
||||
scrollElRef,
|
||||
headerHeight,
|
||||
}: {
|
||||
moderationOpts: ModerationOpts
|
||||
labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
scrollElRef: ListRef
|
||||
headerHeight: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const contextScrollHandlers = useScrollHandlers()
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onBeginDrag(e, ctx) {
|
||||
contextScrollHandlers.onBeginDrag?.(e, ctx)
|
||||
},
|
||||
onEndDrag(e, ctx) {
|
||||
contextScrollHandlers.onEndDrag?.(e, ctx)
|
||||
},
|
||||
onScroll(e, ctx) {
|
||||
contextScrollHandlers.onScroll?.(e, ctx)
|
||||
},
|
||||
})
|
||||
|
||||
const {labelValues} = labelerInfo.policies
|
||||
const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts)
|
||||
const labelDefs = React.useMemo(() => {
|
||||
const customDefs = interpretLabelValueDefinitions(labelerInfo)
|
||||
return labelValues
|
||||
.map(val => lookupLabelValueDefinition(val, customDefs))
|
||||
.filter(
|
||||
def => def && def?.configurable,
|
||||
) as InterpretedLabelValueDefinition[]
|
||||
}, [labelerInfo, labelValues])
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
// @ts-ignore TODO fix this
|
||||
ref={scrollElRef}
|
||||
scrollEventThrottle={1}
|
||||
contentContainerStyle={{
|
||||
paddingTop: headerHeight,
|
||||
borderWidth: 0,
|
||||
}}
|
||||
contentOffset={{x: 0, y: headerHeight * -1}}
|
||||
onScroll={scrollHandler}>
|
||||
<View
|
||||
style={[
|
||||
a.pt_xl,
|
||||
a.px_lg,
|
||||
isNative && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View>
|
||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
|
||||
<Trans>
|
||||
Labels are annotations on users and content. They can be used to
|
||||
hide, warn, and categorize the network.
|
||||
</Trans>
|
||||
</Text>
|
||||
{labelerInfo.creator.viewer?.blocking ? (
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
|
||||
<CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
|
||||
<Trans>
|
||||
Blocking does not prevent this labeler from placing labels on
|
||||
your account.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{labelValues.length === 0 ? (
|
||||
<Text
|
||||
style={[
|
||||
a.pt_xl,
|
||||
t.atoms.text_contrast_high,
|
||||
a.leading_snug,
|
||||
a.text_sm,
|
||||
]}>
|
||||
<Trans>
|
||||
This labeler hasn't declared what labels it publishes, and may
|
||||
not be active.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : !isSubscribed ? (
|
||||
<Text
|
||||
style={[
|
||||
a.pt_xl,
|
||||
t.atoms.text_contrast_high,
|
||||
a.leading_snug,
|
||||
a.text_sm,
|
||||
]}>
|
||||
<Trans>
|
||||
Subscribe to @{labelerInfo.creator.handle} to use these labels:
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{labelDefs.length > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.mt_xl,
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{labelDefs.map((labelDef, i) => {
|
||||
return (
|
||||
<React.Fragment key={labelDef.identifier}>
|
||||
{i !== 0 && <Divider />}
|
||||
<ModerationLabelPref
|
||||
disabled={isSubscribed ? undefined : true}
|
||||
labelValueDefinition={labelDef}
|
||||
labelerDid={labelerInfo.creator.did}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{height: 400}} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
3
src/screens/Profile/Sections/types.ts
Normal file
3
src/screens/Profile/Sections/types.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export interface SectionRef {
|
||||
scrollToTop: () => void
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue