diff --git a/src/components/Button.tsx b/src/components/Button.tsx index d71786dfc9..b9820e29a1 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -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 : []), diff --git a/src/screens/Profile/FiltersSection.tsx b/src/screens/Profile/FiltersSection.tsx new file mode 100644 index 0000000000..726d50267c --- /dev/null +++ b/src/screens/Profile/FiltersSection.tsx @@ -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 ( + + + {isLoading ? ( + + + + ) : error || !(preferences && modservice) ? ( + + ) : ( + + )} + + + ) +} + +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( + modservice.viewer?.like || '', + ) + // TODO error state + const [_enablementError, setEnablementError] = React.useState('') + + 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 ( + + {isSubscribed ? ( + + + + Enable or disable labels from this service. + + + + + {isEnabled ? 'Enabled' : 'Disabled'} + + + + ) : ( + + + This labeler moderates the following types of content. + + + )} + + + {groups.map((def, i) => { + return ( + + {i !== 0 && } + + + + + ) + })} + + + + + ) +} diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx new file mode 100644 index 0000000000..b6d88db712 --- /dev/null +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -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 + moderation: ModerationDecision +}) { + const t = useTheme() + return ( + + + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + + + ) +} diff --git a/src/screens/Profile/Header/DropdownBtn.tsx b/src/screens/Profile/Header/DropdownBtn.tsx new file mode 100644 index 0000000000..24244b6646 --- /dev/null +++ b/src/screens/Profile/Header/DropdownBtn.tsx @@ -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 +}) { + 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 ? ( + + + + + + ) : null +} diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx new file mode 100644 index 0000000000..53961c961d --- /dev/null +++ b/src/screens/Profile/Header/Handle.tsx @@ -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 +}) { + const t = useTheme() + const invalidHandle = isInvalidHandle(profile.handle) + const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy + return ( + + {profile.viewer?.followedBy && + !blockHide && + !profile.associated?.modservice ? ( + + + Follows you + + + ) : undefined} + + {invalidHandle ? ⚠Invalid Handle : `@${profile.handle}`} + + {profile.associated?.modservice ? ( + + + Moderation service + + + ) : undefined} + + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderModerator.tsx b/src/screens/Profile/Header/ProfileHeaderModerator.tsx new file mode 100644 index 0000000000..9c5d36a58f --- /dev/null +++ b/src/screens/Profile/Header/ProfileHeaderModerator.tsx @@ -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 = + 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( + /* 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 ( + + + + {isMe ? ( + + ) : ( + <> + + + )} + + + + + + + {!isPlaceholderProfile && ( + <> + + + + {typeof modservice.likeCount === 'number' && ( + + + Liked by {modservice.likeCount}{' '} + {pluralize(modservice.likeCount, 'user')} + + + )} + + {descriptionRT && !moderation.ui('profileView').blur ? ( + + + + ) : undefined} + + )} + + + ) +} +ProfileHeaderModerator = memo(ProfileHeaderModerator) +export {ProfileHeaderModerator} diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx new file mode 100644 index 0000000000..e12b259e36 --- /dev/null +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -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 = + 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 ( + + + + {isMe ? ( + + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( + + ) + ) : !profile.viewer?.blockedBy ? ( + <> + {hasSession && ( + + )} + + + + ) : null} + + + + + + + {!isPlaceholderProfile && ( + <> + + + + {followers}{' '} + + + {pluralizedFollowers} + + + + + + {following}{' '} + + + following + + + + + {formatCount(profile.postsCount || 0)}{' '} + + {pluralize(profile.postsCount || 0, 'post')} + + + + {descriptionRT && !moderation.ui('profileView').blur ? ( + + + + ) : undefined} + + )} + + {showSuggestedFollows && ( + { + if (showSuggestedFollows) { + setShowSuggestedFollows(false) + } else { + track('ProfileHeader:SuggestedFollowsOpened') + setShowSuggestedFollows(true) + } + }} + /> + )} + + ) +} +ProfileHeaderStandard = memo(ProfileHeaderStandard) +export {ProfileHeaderStandard} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx new file mode 100644 index 0000000000..8843300c9b --- /dev/null +++ b/src/screens/Profile/Header/Shell.tsx @@ -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 + moderation: ModerationDecision + hideBackButton?: boolean + isPlaceholderProfile?: boolean +} + +let ProfileHeaderShell = ({ + children, + profile, + moderation, + hideBackButton = false, + isPlaceholderProfile, +}: React.PropsWithChildren): React.ReactNode => { + const t = useTheme() + const {currentAccount} = useSession() + const {_} = useLingui() + const {openLightbox} = useLightboxControls() + const navigation = useNavigation() + 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 ( + + + {isPlaceholderProfile ? ( + + ) : ( + + )} + + + {children} + + + + {isMe && ( + + )} + + + {!isDesktop && !hideBackButton && ( + + + + + + + + )} + + + + + + + ) +} +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, + }, +}) diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx new file mode 100644 index 0000000000..ea39d9c44f --- /dev/null +++ b/src/screens/Profile/Header/index.tsx @@ -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 ( + + + + + + + + + + + + ) +} +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 + } + return +} +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}, +}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 4e3586a463..b208062c61 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -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 ( - (null) + const repliesSectionRef = React.useRef(null) + const mediaSectionRef = React.useRef(null) + const likesSectionRef = React.useRef(null) + const feedsSectionRef = React.useRef(null) + const listsSectionRef = React.useRef(null) + const filtersSectionRef = React.useRef(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(() => { + 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 ( + + ) + }, [ + profile, + descriptionRT, + hasDescription, + moderationOpts, + hideBackButton, + showPlaceholder, + ]) + + return ( + + + {showFiltersTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} + {showPostsTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} + {showRepliesTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} + {showMediaTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} + {showLikesTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} + {showFeedsTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} + {showListsTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} + + {hasSession && ( + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + )} + + ) +} + interface FeedSectionProps { feed: FeedDescriptor headerHeight: number @@ -548,6 +860,55 @@ function ModerationSection({did}: {did: string}) { ) } +function FiltersSection({did}: {did: string}) { + const t = useTheme() + return ( + + ( + + {ctx => ( + + + + + + + )} + + )} + /> + + ) +} + function useRichText(text: string): [RichTextAPI, boolean] { const [prevText, setPrevText] = React.useState(text) const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))