Add moderator-service profile view

This commit is contained in:
Paul Frazee
2024-02-20 22:54:10 -08:00
parent 7d9890d465
commit 67d0dde5b7
10 changed files with 1743 additions and 2 deletions
-1
View File
@@ -380,7 +380,6 @@ export function Button({
a.flex_row,
a.align_center,
a.justify_center,
a.justify_center,
flattenedBaseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []),
+238
View File
@@ -0,0 +1,238 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyModerationDefs, AppBskyActorDefs} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import * as Toast from '#/view/com/util/Toast'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {Haptics} from '#/lib/haptics'
import {useAnalytics} from '#/lib/analytics/analytics'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {logger} from '#/logger'
import {
useModServiceInfoQuery,
useModServiceEnableMutation,
} from '#/state/queries/modservice'
import {
UsePreferencesQueryResponse,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
import {getLabelGroupsFromLabels} from '#/lib/moderation'
import {useTheme, atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {Loader} from '#/components/Loader'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {ErrorState} from '#/screens/ProfileModerationService/ErrorState'
import {PreferenceRow} from '#/screens/ProfileModerationService/PreferenceRow'
export function ProfileFiltersSection({
profile,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
}) {
const t = useTheme()
const {_} = useLingui()
const {height: minHeight} = useSafeAreaFrame()
const {
isLoading: isPreferencesLoading,
error: preferencesError,
data: preferences,
} = usePreferencesQuery()
const {
isLoading: isModServiceLoading,
error: modServiceError,
data: modservice,
} = useModServiceInfoQuery({did: profile.did})
const isLoading = isPreferencesLoading || isModServiceLoading
const error = preferencesError || modServiceError
return (
<CenteredView>
<View
style={[
a.border_l,
a.border_r,
a.border_t,
t.atoms.border_contrast_low,
{
minHeight,
},
]}>
{isLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="xl" />
</View>
) : error || !(preferences && modservice) ? (
<ErrorState
error={
error?.toString() ||
_(msg`Something went wrong, please try again.`)
}
/>
) : (
<ProfileFiltersSectionInner
preferences={preferences}
modservice={modservice}
/>
)}
</View>
</CenteredView>
)
}
export function ProfileFiltersSectionInner({
preferences,
modservice,
}: {
preferences: UsePreferencesQueryResponse
modservice: AppBskyModerationDefs.ModServiceViewDetailed
}) {
const t = useTheme()
const {_} = useLingui()
const {hasSession} = useSession()
const {track} = useAnalytics()
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
useUnlikeMutation()
const {mutateAsync: toggleEnabled, variables: enabledVariables} =
useModServiceEnableMutation()
const [likeUri, setLikeUri] = React.useState<string>(
modservice.viewer?.like || '',
)
// TODO error state
const [_enablementError, setEnablementError] = React.useState<string>('')
const isLiked = !!likeUri
const groups = React.useMemo(() => {
return getLabelGroupsFromLabels(modservice.policies.labelValues).filter(
def => def.configurable,
)
}, [modservice.policies.labelValues])
const modservicePreferences = React.useMemo(() => {
return preferences.moderationOpts.mods.find(
p => p.did === modservice.creator.did,
)
}, [modservice.creator.did, preferences.moderationOpts.mods])
const isSubscribed = preferences.moderationOpts.mods.find(
mod => mod.did === modservice.creator.did,
)
const isEnabled = Boolean(
enabledVariables?.enabled ??
preferences.moderationOpts.mods.find(
mod => mod.did === modservice.creator.did && mod.enabled,
),
)
useSetTitle(modservice.creator.displayName || modservice.creator.handle)
const onToggleLiked = React.useCallback(async () => {
try {
Haptics.default()
if (isLiked && likeUri) {
await unlikeMod({uri: likeUri})
track('CustomFeed:Unlike')
setLikeUri('')
} else {
const res = await likeMod({uri: modservice.uri, cid: modservice.cid})
track('CustomFeed:Like')
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})
}
}, [likeUri, isLiked, modservice, likeMod, unlikeMod, track, _])
const onToggleLabelerEnabled = React.useCallback(async () => {
try {
await toggleEnabled({
did: modservice.creator.did,
enabled: !isEnabled,
})
} catch (e: any) {
setEnablementError(e.message)
logger.error(`Failed to toggle labeler enabled`, {message: e.message})
}
}, [toggleEnabled, isEnabled, modservice.creator.did])
return (
<ScrollView
scrollEventThrottle={1}
contentContainerStyle={{
borderWidth: 0,
paddingHorizontal: a.px_xl.paddingLeft,
}}>
{isSubscribed ? (
<View style={[a.flex_row, a.pr_lg, a.pt_xl]}>
<View style={[a.gap_sm, a.flex_1]}>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
Enable or disable labels from this service.
</Text>
</View>
<Toggle.Item
name="enable"
value={isEnabled}
onChange={onToggleLabelerEnabled}
label={
isEnabled
? _(msg`Disable this moderation service`)
: _(msg`Enable this moderation service`)
}>
<Toggle.Label>{isEnabled ? 'Enabled' : 'Disabled'}</Toggle.Label>
<Toggle.Switch />
</Toggle.Item>
</View>
) : (
<View style={[a.gap_sm, a.pt_xl]}>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
This labeler moderates the following types of content.
</Text>
</View>
)}
<View
style={[
a.gap_md,
a.mt_xl,
t.atoms.bg_contrast_25,
a.rounded_md,
a.border,
a.py_md,
t.atoms.border_contrast_low,
]}>
{groups.map((def, i) => {
return (
<React.Fragment key={def.id}>
{i !== 0 && <Divider />}
<View style={[a.px_lg]}>
<PreferenceRow
disabled={isEnabled ? undefined : true}
labelGroup={def.id}
modservicePreferences={modservicePreferences}
/>
</View>
</React.Fragment>
)
})}
</View>
<View style={{height: 100}} />
</ScrollView>
)
}
@@ -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>
)
}
+281
View File
@@ -0,0 +1,281 @@
import React from 'react'
import {View} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from 'platform/detection'
import {useModalControls} from '#/state/modals'
import {
RQKEY as profileQueryKey,
useProfileMuteMutationQueue,
useProfileBlockMutationQueue,
} from '#/state/queries/profile'
import {useAnalytics} from 'lib/analytics/analytics'
import {makeProfileLink} from 'lib/routes/links'
import {toShareUrl} from 'lib/strings/url-helpers'
import {shareUrl} from 'lib/sharing'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {Shadow} from '#/state/cache/types'
import {NEW_REPORT_DIALOG_ENABLED} from '#/lib/build-flags'
import {atoms as a, useTheme, tokens} from '#/alf'
import * as Toast from 'view/com/util/Toast'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {useOpenGlobalDialog} from '#/components/dialogs'
import {ReportDialog} from '#/components/dialogs/ReportDialog'
import {DotGrid1x3Horizontal_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
export function ProfileHeaderDropdownBtn({
profile,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}) {
const t = useTheme()
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const {openModal} = useModalControls()
const {track} = useAnalytics()
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const queryClient = useQueryClient()
const openDialog = useOpenGlobalDialog()
const invalidateProfileQuery = React.useCallback(() => {
queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did),
})
}, [queryClient, profile.did])
const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
shareUrl(toShareUrl(makeProfileLink(profile)))
}, [track, profile])
const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked')
openModal({
name: 'user-add-remove-lists',
subject: profile.did,
handle: profile.handle,
displayName: profile.displayName || profile.handle,
onAdd: invalidateProfileQuery,
onRemove: invalidateProfileQuery,
})
}, [track, profile, openModal, invalidateProfileQuery])
const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await queueMute()
Toast.show(_(msg`Account muted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [track, queueMute, _])
const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await queueUnmute()
Toast.show(_(msg`Account unmuted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [track, queueUnmute, _])
const onPressBlockAccount = React.useCallback(async () => {
track('ProfileHeader:BlockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Block Account`),
message: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
),
onPressConfirm: async () => {
try {
await queueBlock()
Toast.show(_(msg`Account blocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
},
})
}, [track, queueBlock, openModal, _])
const onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Unblock Account`),
message: _(
msg`The account will be able to interact with you after unblocking.`,
),
onPressConfirm: async () => {
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()}`))
}
}
},
})
}, [track, queueUnblock, openModal, _])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
if (NEW_REPORT_DIALOG_ENABLED) {
openDialog(ReportDialog, {type: 'profile', did: profile.did})
} else {
openModal({
name: 'report',
did: profile.did,
})
}
}, [track, openModal, profile, openDialog])
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'profileHeaderDropdownShareBtn',
label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`),
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
]
if (hasSession) {
if (!profile.associated?.modservice) {
items.push({label: 'separator'})
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: _(msg`Add to Lists`),
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
android: 'ic_menu_add',
web: 'list',
},
})
if (!isMe) {
if (!profile.viewer?.blocking) {
if (!profile.viewer?.mutedByList) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
label: profile.viewer?.muted
? _(msg`Unmute Account`)
: _(msg`Mute Account`),
onPress: profile.viewer?.muted
? onPressUnmuteAccount
: onPressMuteAccount,
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
})
}
}
if (!profile.viewer?.blockingByList) {
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: profile.viewer?.blocking
? _(msg`Unblock Account`)
: _(msg`Block Account`),
onPress: profile.viewer?.blocking
? onPressUnblockAccount
: onPressBlockAccount,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
},
})
}
}
}
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: _(msg`Report Account`),
onPress: onPressReportAccount,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
})
}
return items
}, [
isMe,
hasSession,
profile.associated?.modservice,
profile.viewer?.muted,
profile.viewer?.mutedByList,
profile.viewer?.blocking,
profile.viewer?.blockingByList,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
onPressUnblockAccount,
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
_,
])
return dropdownItems?.length ? (
<NativeDropdown
testID="profileHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View
style={[
{
height: 40,
width: 40,
backgroundColor:
t.name === 'light' ? tokens.color.gray_50 : tokens.color.gray_900,
},
a.flex_row,
a.align_center,
a.justify_center,
a.rounded_full,
]}>
<Ellipsis width={20} fill={t.atoms.text_contrast_medium.color} />
</View>
</NativeDropdown>
) : null
}
+55
View File
@@ -0,0 +1,55 @@
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 &&
!profile.associated?.modservice ? (
<View style={[t.atoms.bg_contrast_50, 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>
{profile.associated?.modservice ? (
<View style={[t.atoms.bg_contrast_50, a.rounded_xs, a.px_sm, a.py_xs]}>
<Text style={[t.atoms.text, a.text_sm]}>
<Trans>Moderation service</Trans>
</Text>
</View>
) : undefined}
</View>
)
}
@@ -0,0 +1,228 @@
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 {RichText} from 'view/com/util/text/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 {useModServiceSubscriptionMutation} from '#/state/queries/modservice'
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
import {logger} from '#/logger'
import {Haptics} from '#/lib/haptics'
import {pluralize} from '#/lib/strings/helpers'
import {atoms as a, useTheme} from '#/alf'
import {InlineLink} from '#/components/Link'
import {Button, ButtonText} from '#/components/Button'
import * as Toast from '#/view/com/util/Toast'
import {ProfileHeaderShell} from './Shell'
import {ProfileHeaderDropdownBtn} from './DropdownBtn'
import {ProfileHeaderDisplayName} from './DisplayName'
import {ProfileHeaderHandle} from './Handle'
import {
Heart2_Stroke2_Corner0_Rounded as Heart,
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
} from '#/components/icons/Heart2'
interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed
descriptionRT: RichTextAPI | null
moderationOpts: ModerationOpts
hideBackButton?: boolean
isPlaceholderProfile?: boolean
}
let ProfileHeaderModerator = ({
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 {data: preferences} = usePreferencesQuery()
const {mutateAsync: toggleSubscription, variables} =
useModServiceSubscriptionMutation()
const isSubscribed =
variables?.subscribe ??
preferences?.moderationOpts.mods.find(mod => mod.did === profile.did)
const modservice = {likeCount: 0} // TODO
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
useUnlikeMutation()
const [likeUri, setLikeUri] = React.useState<string>(
/* TODO modservice.viewer?.like ||*/ '',
)
const isLiked = !!likeUri
const onToggleLiked = React.useCallback(async () => {
try {
Haptics.default()
if (isLiked && likeUri) {
await unlikeMod({uri: likeUri})
track('CustomFeed:Unlike')
setLikeUri('')
} else {
// TODO
// const res = await likeMod({uri: modservice.uri, cid: modservice.cid})
track('CustomFeed:Like')
// 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})
}
}, [likeUri, isLiked, likeMod, unlikeMod, track, _])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
openModal({
name: 'edit-profile',
profile,
})
}, [track, openModal, profile])
const onPressSubscribe = React.useCallback(async () => {
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])
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>
) : (
<>
<Button
testID="toggleSubscribeBtn"
size="small"
color={isSubscribed ? 'secondary' : 'primary'}
variant="solid"
label={
isSubscribed
? _(msg`Unsubscribe from this labeler`)
: _(msg`Subscribe to this labeler`)
}
disabled={!hasSession}
onPress={onPressSubscribe}
style={a.rounded_full}>
<ButtonText>
{isSubscribed ? (
<Trans>Unsubscribe</Trans>
) : (
<Trans>Subscribe</Trans>
)}
</ButtonText>
</Button>
</>
)}
<ProfileHeaderDropdownBtn profile={profile} />
</View>
<View style={[a.flex_col, a.gap_xs, a.pb_md]}>
<ProfileHeaderDisplayName profile={profile} moderation={moderation} />
<ProfileHeaderHandle profile={profile} />
</View>
{!isPlaceholderProfile && (
<>
<View style={[a.flex_row, a.gap_md, a.align_center, a.pb_md]}>
<Button
testID="toggleLikeBtn"
size="small"
color="secondary"
variant="solid"
shape="round"
label={_(msg`Like this feed`)}
disabled={!hasSession || isLikePending || isUnlikePending}
onPress={onToggleLiked}>
{isLiked ? (
<HeartFilled fill={t.palette.negative_400} />
) : (
<Heart fill={t.atoms.text_contrast_medium.color} />
)}
</Button>
{typeof modservice.likeCount === 'number' && (
<InlineLink
to={'#todo'}
style={[t.atoms.text_contrast_medium, a.font_bold]}>
<Trans>
Liked by {modservice.likeCount}{' '}
{pluralize(modservice.likeCount, 'user')}
</Trans>
</InlineLink>
)}
</View>
{descriptionRT && !moderation.ui('profileView').blur ? (
<View pointerEvents="auto">
<RichText
testID="profileHeaderDescription"
style={t.atoms.text}
numberOfLines={15}
richText={descriptionRT}
/>
</View>
) : undefined}
</>
)}
</View>
</ProfileHeaderShell>
)
}
ProfileHeaderModerator = memo(ProfileHeaderModerator)
export {ProfileHeaderModerator}
@@ -0,0 +1,317 @@
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 {pluralize} from '#/lib/strings/helpers'
import {makeProfileLink} from 'lib/routes/links'
import {formatCount} from 'view/com/util/numeric/format'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {InlineLink} from '#/components/Link'
import {Button, ButtonText} from '#/components/Button'
import * as Toast from '#/view/com/util/Toast'
import {ProfileHeaderShell} from './Shell'
import {ProfileHeaderDropdownBtn} from './DropdownBtn'
import {ProfileHeaderDisplayName} from './DisplayName'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {RichText} from 'view/com/util/text/RichText'
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)
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
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,
)}`,
),
)
} 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 onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Unblock Account`),
message: _(
msg`The account will be able to interact with you after unblocking.`,
),
onPressConfirm: async () => {
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()}`))
}
}
},
})
}, [track, queueUnblock, openModal, _])
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
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={onPressUnblockAccount}
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}>
<ButtonText>
{profile.viewer?.following ? (
<Trans>Following</Trans>
) : (
<Trans>Follow</Trans>
)}
</ButtonText>
</Button>
</>
) : null}
<ProfileHeaderDropdownBtn profile={profile} />
</View>
<View style={[a.flex_col, a.gap_xs, a.pb_sm]}>
<ProfileHeaderDisplayName profile={profile} moderation={moderation} />
<ProfileHeaderHandle profile={profile} />
</View>
{!isPlaceholderProfile && (
<>
<View
style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]}
pointerEvents="box-none">
<InlineLink
testID="profileHeaderFollowersButton"
style={a.flex_row}
to={makeProfileLink(profile, 'followers')}
label={`${followers} ${pluralizedFollowers}`}>
<Text style={[a.font_bold, t.atoms.text, 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}
to={makeProfileLink(profile, 'follows')}
label={_(msg`${following} following`)}>
<Trans>
<Text style={[a.font_bold, t.atoms.text, 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>
{descriptionRT && !moderation.ui('profileView').blur ? (
<View pointerEvents="auto">
<RichText
testID="profileHeaderDescription"
style={t.atoms.text}
numberOfLines={15}
richText={descriptionRT}
/>
</View>
) : undefined}
</>
)}
</View>
{showSuggestedFollows && (
<ProfileHeaderSuggestedFollows
actorDid={profile.did}
requestDismiss={() => {
if (showSuggestedFollows) {
setShowSuggestedFollows(false)
} else {
track('ProfileHeader:SuggestedFollowsOpened')
setShowSuggestedFollows(true)
}
}}
/>
)}
</ProfileHeaderShell>
)
}
ProfileHeaderStandard = memo(ProfileHeaderStandard)
export {ProfileHeaderStandard}
+158
View File
@@ -0,0 +1,158 @@
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 'view/com/util/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 'view/com/util/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
banner={profile.banner}
moderation={moderation.ui('banner')}
/>
)}
</View>
{children}
<View style={[a.px_lg]} 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,
]}>
<UserAvatar
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,
},
})
+73
View File
@@ -0,0 +1,73 @@
import React, {memo} from 'react'
import {StyleSheet, View} from 'react-native'
import {
AppBskyActorDefs,
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 {ProfileHeaderModerator} from './ProfileHeaderModerator'
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 = (props: Props): React.ReactNode => {
if (props.profile.associated?.modservice) {
return <ProfileHeaderModerator {...props} />
}
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},
})
+362 -1
View File
@@ -52,6 +52,8 @@ import {isInvalidHandle} from '#/lib/strings/handles'
import {useTheme, atoms as a} from '#/alf'
import * as ModerationServiceCard from '#/components/ModerationServiceCard'
import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand'
import {ProfileFiltersSection} from '#/screens/Profile/FiltersSection'
import {ProfileHeader as ProfileHeaderV2} from '#/screens/Profile/Header'
interface SectionRef {
scrollToTop: () => void
@@ -115,8 +117,11 @@ export function ProfileScreen({route}: Props) {
)
}
if (profile && moderationOpts) {
if (profile.handle === 'alice.test') {
profile.associated = {modservice: true}
}
return (
<ProfileScreenLoaded
<ProfileScreenLoadedV2
profile={profile}
moderationOpts={moderationOpts}
isPlaceholderProfile={isPlaceholderProfile}
@@ -428,6 +433,313 @@ function ProfileScreenLoaded({
)
}
function ProfileScreenLoadedV2({
profile: profileUnshadowed,
isPlaceholderProfile,
moderationOpts,
hideBackButton,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
hideBackButton: boolean
isPlaceholderProfile: boolean
}) {
const profile = useProfileShadow(profileUnshadowed)
const {hasSession, currentAccount} = useSession()
const setMinimalShellMode = useSetMinimalShellMode()
const {openComposer} = useComposerControls()
const {screen, track} = useAnalytics()
const [currentPage, setCurrentPage] = React.useState(0)
const {_} = useLingui()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
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 filtersSectionRef = React.useRef<SectionRef>(null)
useSetTitle(combinedDisplayName(profile))
const description = profile.description ?? ''
const hasDescription = description !== ''
const [descriptionRT, isResolvingDescriptionRT] = useRichText(description)
const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const isMe = profile.did === currentAccount?.did
const isModService = !!profile.associated?.modservice
const showFiltersTab = hasSession && profile.associated?.modservice
const showPostsTab = !isModService
const showRepliesTab = hasSession && !isModService
const showMediaTab = !isModService
const showLikesTab = isMe && !isModService
const showFeedsTab =
hasSession && (isMe || (profile.associated?.feedgens || 0) > 0)
const showListsTab =
hasSession && (isMe || (profile.associated?.lists || 0) > 0)
const sectionTitles = useMemo<string[]>(() => {
return [
showFiltersTab ? _(msg`Content Filters`) : undefined,
showPostsTab ? _(msg`Posts`) : undefined,
showRepliesTab ? _(msg`Replies`) : undefined,
showMediaTab ? _(msg`Media`) : undefined,
showLikesTab ? _(msg`Likes`) : undefined,
showFeedsTab ? _(msg`Feeds`) : undefined,
showListsTab ? _(msg`Lists`) : undefined,
].filter(Boolean) as string[]
}, [
showPostsTab,
showRepliesTab,
showMediaTab,
showLikesTab,
showFeedsTab,
showListsTab,
showFiltersTab,
_,
])
let nextIndex = 0
let filtersIndex: number | null = null
if (showFiltersTab) {
filtersIndex = nextIndex++
}
let postsIndex: number | null = null
if (showPostsTab) {
postsIndex = nextIndex++
}
let repliesIndex: number | null = null
if (showRepliesTab) {
repliesIndex = nextIndex++
}
let mediaIndex: number | null = null
if (showMediaTab) {
mediaIndex = nextIndex++
}
let likesIndex: number | null = null
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 === filtersIndex) {
filtersSectionRef.current?.scrollToTop()
} else if (index === postsIndex) {
postsSectionRef.current?.scrollToTop()
} else if (index === repliesIndex) {
repliesSectionRef.current?.scrollToTop()
} else if (index === mediaIndex) {
mediaSectionRef.current?.scrollToTop()
} else if (index === likesIndex) {
likesSectionRef.current?.scrollToTop()
} else if (index === feedsIndex) {
feedsSectionRef.current?.scrollToTop()
} else if (index === listsIndex) {
listsSectionRef.current?.scrollToTop()
}
},
[
filtersIndex,
postsIndex,
repliesIndex,
mediaIndex,
likesIndex,
feedsIndex,
listsIndex,
],
)
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
screen('Profile')
return listenSoftReset(() => {
scrollSectionToTop(currentPage)
})
}, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]),
)
useFocusEffect(
React.useCallback(() => {
setDrawerSwipeDisabled(currentPage > 0)
return () => {
setDrawerSwipeDisabled(false)
}
}, [setDrawerSwipeDisabled, currentPage]),
)
// events
// =
const onPressCompose = React.useCallback(() => {
track('ProfileScreen:PressCompose')
const mention =
profile.handle === currentAccount?.handle ||
isInvalidHandle(profile.handle)
? undefined
: profile.handle
openComposer({mention})
}, [openComposer, currentAccount, track, profile])
const onPageSelected = React.useCallback(
(i: number) => {
setCurrentPage(i)
},
[setCurrentPage],
)
const onCurrentPageSelected = React.useCallback(
(index: number) => {
scrollSectionToTop(index)
},
[scrollSectionToTop],
)
// rendering
// =
const renderHeader = React.useCallback(() => {
return (
<ProfileHeaderV2
profile={profile}
descriptionRT={hasDescription ? descriptionRT : null}
moderationOpts={moderationOpts}
hideBackButton={hideBackButton}
isPlaceholderProfile={showPlaceholder}
/>
)
}, [
profile,
descriptionRT,
hasDescription,
moderationOpts,
hideBackButton,
showPlaceholder,
])
return (
<ScreenHider
testID="profileView"
style={styles.container}
screenDescription="profile"
modui={moderation.ui('profileView')}>
<PagerWithHeader
testID="profilePager"
isHeaderReady={!showPlaceholder}
items={sectionTitles}
onPageSelected={onPageSelected}
onCurrentPageSelected={onCurrentPageSelected}
renderHeader={renderHeader}>
{showFiltersTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileFiltersSection
// ref={moderationSectionRef}
profile={profile}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
enabled={isFocused}
/>
)
: null}
{showPostsTab
? ({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}
/>
)
: null}
{showRepliesTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
ref={repliesSectionRef}
feed={`author|${profile.did}|posts_with_replies`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)
: null}
{showMediaTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
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
ref={likesSectionRef}
feed={`likes|${profile.did}`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)
: null}
{showFeedsTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileFeedgens
ref={feedsSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
enabled={isFocused}
/>
)
: null}
{showListsTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileLists
ref={listsSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
enabled={isFocused}
/>
)
: null}
</PagerWithHeader>
{hasSession && (
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
)}
</ScreenHider>
)
}
interface FeedSectionProps {
feed: FeedDescriptor
headerHeight: number
@@ -548,6 +860,55 @@ function ModerationSection({did}: {did: string}) {
)
}
function FiltersSection({did}: {did: string}) {
const t = useTheme()
return (
<ScrollView>
<ModerationServiceCard.Loader
did={did}
component={({modservice}) => (
<ModerationServiceCard.Link modservice={modservice}>
{ctx => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_md,
a.p_md,
a.border_t,
t.atoms.border_contrast_low,
...(ctx.focused || ctx.hovered
? [t.atoms.bg_contrast_25]
: []),
]}>
<View
style={[
{backgroundColor: t.palette.negative_25},
a.p_lg,
a.rounded_sm,
]}>
<RaisingHand
width={36}
style={[a.z_10]}
fill={t.palette.negative_500}
/>
</View>
<ModerationServiceCard.Card.Content
title="Moderation service"
description={modservice.description}
handle={modservice.creator.handle}
likeCount={modservice.likeCount}
/>
</View>
)}
</ModerationServiceCard.Link>
)}
/>
</ScrollView>
)
}
function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))