Add moderator-service profile view
This commit is contained in:
@@ -380,7 +380,6 @@ export function Button({
|
|||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
a.justify_center,
|
|
||||||
flattenedBaseStyles,
|
flattenedBaseStyles,
|
||||||
...(state.hovered || state.pressed ? hoverStyles : []),
|
...(state.hovered || state.pressed ? hoverStyles : []),
|
||||||
...(state.focused ? focusStyles : []),
|
...(state.focused ? focusStyles : []),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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},
|
||||||
|
})
|
||||||
@@ -52,6 +52,8 @@ import {isInvalidHandle} from '#/lib/strings/handles'
|
|||||||
import {useTheme, atoms as a} from '#/alf'
|
import {useTheme, atoms as a} from '#/alf'
|
||||||
import * as ModerationServiceCard from '#/components/ModerationServiceCard'
|
import * as ModerationServiceCard from '#/components/ModerationServiceCard'
|
||||||
import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand'
|
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 {
|
interface SectionRef {
|
||||||
scrollToTop: () => void
|
scrollToTop: () => void
|
||||||
@@ -115,8 +117,11 @@ export function ProfileScreen({route}: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (profile && moderationOpts) {
|
if (profile && moderationOpts) {
|
||||||
|
if (profile.handle === 'alice.test') {
|
||||||
|
profile.associated = {modservice: true}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ProfileScreenLoaded
|
<ProfileScreenLoadedV2
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
isPlaceholderProfile={isPlaceholderProfile}
|
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 {
|
interface FeedSectionProps {
|
||||||
feed: FeedDescriptor
|
feed: FeedDescriptor
|
||||||
headerHeight: number
|
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] {
|
function useRichText(text: string): [RichTextAPI, boolean] {
|
||||||
const [prevText, setPrevText] = React.useState(text)
|
const [prevText, setPrevText] = React.useState(text)
|
||||||
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
|
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
|
||||||
|
|||||||
Reference in New Issue
Block a user