Merge branch 'bluesky-social:main' into patch-3

This commit is contained in:
Minseo Lee 2024-03-13 10:30:07 +09:00 committed by GitHub
commit 3ead08ab26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1575 additions and 1382 deletions

View file

@ -49,7 +49,7 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
import {insertMentionAt} from 'lib/strings/mention-manip'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModals, useModalControls} from '#/state/modals'
import {useModals} from '#/state/modals'
import {useRequireAltTextEnabled} from '#/state/preferences'
import {
useLanguagePrefs,
@ -63,6 +63,8 @@ import {emitPostCreated} from '#/state/events'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {logger} from '#/logger'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import * as Prompt from '#/components/Prompt'
import {useDialogStateControlContext} from 'state/dialogs'
type Props = ComposerOpts
export const ComposePost = observer(function ComposePost({
@ -76,8 +78,7 @@ export const ComposePost = observer(function ComposePost({
}: Props) {
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const {isModalActive, activeModals} = useModals()
const {openModal, closeModal} = useModalControls()
const {isModalActive} = useModals()
const {closeComposer} = useComposerControls()
const {track} = useAnalytics()
const pal = usePalette('default')
@ -87,6 +88,9 @@ export const ComposePost = observer(function ComposePost({
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext()
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
const [isProcessing, setIsProcessing] = useState(false)
const [processingState, setProcessingState] = useState('')
@ -134,27 +138,21 @@ export const ComposePost = observer(function ComposePost({
const onPressCancel = useCallback(() => {
if (graphemeLength > 0 || !gallery.isEmpty) {
if (activeModals.some(modal => modal.name === 'confirm')) {
closeModal()
}
closeAllDialogs()
if (Keyboard) {
Keyboard.dismiss()
}
openModal({
name: 'confirm',
title: _(msg`Discard draft`),
onPressConfirm: onClose,
onPressCancel: () => {
closeModal()
},
message: _(msg`Are you sure you'd like to discard this draft?`),
confirmBtnText: _(msg`Discard`),
confirmBtnStyle: {backgroundColor: colors.red4},
})
discardPromptControl.open()
} else {
onClose()
}
}, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _])
}, [
graphemeLength,
gallery.isEmpty,
closeAllDialogs,
discardPromptControl,
onClose,
])
// android back button
useEffect(() => {
if (!isAndroid) {
@ -488,6 +486,15 @@ export const ComposePost = observer(function ComposePost({
<CharProgress count={graphemeLength} />
</View>
</View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
</KeyboardAvoidingView>
)
})

View file

@ -1,5 +1,6 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, StyleSheet} from 'react-native'
import * as MediaLibrary from 'expo-media-library'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
@ -24,6 +25,8 @@ export function OpenCameraBtn({gallery}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const [mediaPermissionRes, requestMediaPermission] =
MediaLibrary.usePermissions()
const onPressTakePicture = useCallback(async () => {
track('Composer:CameraOpened')
@ -31,6 +34,9 @@ export function OpenCameraBtn({gallery}: Props) {
if (!(await requestCameraAccessIfNeeded())) {
return
}
if (!mediaPermissionRes?.granted && mediaPermissionRes?.canAskAgain) {
await requestMediaPermission()
}
const img = await openCamera({
width: POST_IMG_MAX.width,
@ -38,12 +44,23 @@ export function OpenCameraBtn({gallery}: Props) {
freeStyleCropEnabled: true,
})
// If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
// the image even without these permissions
if (mediaPermissionRes) {
await MediaLibrary.createAssetAsync(img.path)
}
gallery.add(img)
} catch (err: any) {
// ignore
logger.warn('Error using camera', {error: err})
}
}, [gallery, track, requestCameraAccessIfNeeded])
}, [
gallery,
track,
requestCameraAccessIfNeeded,
mediaPermissionRes,
requestMediaPermission,
])
const shouldShowCameraButton = isNative || isMobileWeb
if (!shouldShowCameraButton) {

View file

@ -6,14 +6,11 @@ import {RichText} from '#/components/RichText'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {UserAvatar} from '../util/UserAvatar'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
import {AtUri} from '@atproto/api'
import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
@ -26,6 +23,8 @@ import {
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {useTheme} from '#/alf'
import * as Prompt from '#/components/Prompt'
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
export function FeedSourceCard({
feedUri,
@ -86,8 +85,8 @@ export function FeedSourceCardLoaded({
const t = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {openModal} = useModalControls()
const removePromptControl = Prompt.usePromptControl()
const navigation = useNavigationDeduped()
const {isPending: isSavePending, mutateAsync: saveFeed} =
useSaveFeedMutation()
@ -97,40 +96,45 @@ export function FeedSourceCardLoaded({
const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || ''))
const onSave = React.useCallback(async () => {
if (!feed) return
try {
if (pinOnSave) {
await pinFeed({uri: feed.uri})
} else {
await saveFeed({uri: feed.uri})
}
Toast.show(_(msg`Added to my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to save feed', {message: e})
}
}, [_, feed, pinFeed, pinOnSave, saveFeed])
const onUnsave = React.useCallback(async () => {
if (!feed) return
try {
await removeFeed({uri: feed.uri})
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {message: e})
}
}, [_, feed, removeFeed])
const onToggleSaved = React.useCallback(async () => {
// Only feeds can be un/saved, lists are handled elsewhere
if (feed?.type !== 'feed') return
if (isSaved) {
openModal({
name: 'confirm',
title: _(msg`Remove from my feeds`),
message: _(msg`Remove ${feed?.displayName} from my feeds?`),
onPressConfirm: async () => {
try {
await removeFeed({uri: feed.uri})
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {message: e})
}
},
})
removePromptControl.open()
} else {
try {
if (pinOnSave) {
await pinFeed({uri: feed.uri})
} else {
await saveFeed({uri: feed.uri})
}
Toast.show(_(msg`Added to my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to save feed', {message: e})
}
await onSave()
}
}, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed])
}, [feed?.type, isSaved, removePromptControl, onSave])
/*
* LOAD STATE
@ -168,25 +172,7 @@ export function FeedSourceCardLoaded({
accessibilityRole="button"
accessibilityLabel={_(msg`Remove from my feeds`)}
accessibilityHint=""
onPress={() => {
openModal({
name: 'confirm',
title: _(msg`Remove from my feeds`),
message: _(msg`Remove this feed from my feeds?`),
onPressConfirm: async () => {
try {
await removeFeed({uri: feedUri})
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(
_(msg`There was an issue contacting your server`),
)
logger.error('Failed to unsave feed', {message: e})
}
},
})
}}
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
<FontAwesomeIcon
@ -200,89 +186,104 @@ export function FeedSourceCardLoaded({
)
return (
<Pressable
testID={`feed-${feed.displayName}`}
accessibilityRole="button"
style={[styles.container, pal.border, style]}
onPress={() => {
if (feed.type === 'feed') {
navigation.push('ProfileFeed', {
name: feed.creatorDid,
rkey: new AtUri(feed.uri).rkey,
})
} else if (feed.type === 'list') {
navigation.push('ProfileList', {
name: feed.creatorDid,
rkey: new AtUri(feed.uri).rkey,
})
}
}}
key={feed.uri}>
<View style={[styles.headerContainer]}>
<View style={[s.mr10]}>
<UserAvatar type="algo" size={36} avatar={feed.avatar} />
</View>
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && feed.type === 'feed' && (
<View style={[s.justifyCenter]}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`)
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
<>
<Pressable
testID={`feed-${feed.displayName}`}
accessibilityRole="button"
style={[styles.container, pal.border, style]}
onPress={() => {
if (feed.type === 'feed') {
navigation.push('ProfileFeed', {
name: feed.creatorDid,
rkey: new AtUri(feed.uri).rkey,
})
} else if (feed.type === 'list') {
navigation.push('ProfileList', {
name: feed.creatorDid,
rkey: new AtUri(feed.uri).rkey,
})
}
}}
key={feed.uri}>
<View style={[styles.headerContainer]}>
<View style={[s.mr10]}>
<UserAvatar type="algo" size={36} avatar={feed.avatar} />
</View>
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && feed.type === 'feed' && (
<View style={[s.justifyCenter]}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved
? _(msg`Remove from my feeds`)
: _(msg`Add to my feeds`)
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
</View>
)}
</View>
{showDescription && feed.description ? (
<RichText
style={[t.atoms.text_contrast_high, styles.description]}
value={feed.description}
numberOfLines={3}
/>
) : null}
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from my feeds?`)}
description={_(
msg`Are you sure you want to remove ${feed.displayName} from your feeds?`,
)}
</View>
{showDescription && feed.description ? (
<RichText
style={[t.atoms.text_contrast_high, styles.description]}
value={feed.description}
numberOfLines={3}
/>
) : null}
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>
onConfirm={onUnsave}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
/>
</>
)
}

View file

@ -1,132 +0,0 @@
import React, {useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {Text} from '../util/text/Text'
import {s, colors} from 'lib/styles'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import type {ConfirmModal} from '#/state/modals'
import {useModalControls} from '#/state/modals'
export const snapPoints = ['50%']
export function Component({
title,
message,
onPressConfirm,
onPressCancel,
confirmBtnText,
confirmBtnStyle,
cancelBtnText,
}: ConfirmModal) {
const pal = usePalette('default')
const {_} = useLingui()
const {closeModal} = useModalControls()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const onPress = async () => {
setError('')
setIsProcessing(true)
try {
await onPressConfirm()
closeModal()
return
} catch (e: any) {
setError(cleanError(e))
setIsProcessing(false)
}
}
return (
<View testID="confirmModal" style={[pal.view, styles.container]}>
<Text type="title-xl" style={[pal.text, styles.title]}>
{title}
</Text>
{typeof message === 'string' ? (
<Text type="xl" style={[pal.textLight, styles.description]}>
{message}
</Text>
) : (
message()
)}
{error ? (
<View style={s.mt10}>
<ErrorMessage message={error} />
</View>
) : undefined}
<View style={s.flex1} />
{isProcessing ? (
<View style={[styles.btn, s.mt10]}>
<ActivityIndicator />
</View>
) : (
<TouchableOpacity
testID="confirmBtn"
onPress={onPress}
style={[styles.btn, confirmBtnStyle]}
accessibilityRole="button"
accessibilityLabel={_(msg({message: 'Confirm', context: 'action'}))}
accessibilityHint="">
<Text style={[s.white, s.bold, s.f18]}>
{confirmBtnText ?? <Trans context="action">Confirm</Trans>}
</Text>
</TouchableOpacity>
)}
{onPressCancel === undefined ? null : (
<TouchableOpacity
testID="cancelBtn"
onPress={onPressCancel}
style={[styles.btnCancel, s.mt10]}
accessibilityRole="button"
accessibilityLabel={_(msg({message: 'Cancel', context: 'action'}))}
accessibilityHint="">
<Text type="button-lg" style={pal.textLight}>
{cancelBtnText ?? <Trans context="action">Cancel</Trans>}
</Text>
</TouchableOpacity>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 10,
paddingBottom: isWeb ? 0 : 60,
},
title: {
textAlign: 'center',
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 22,
marginBottom: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
marginTop: 22,
marginHorizontal: 44,
backgroundColor: colors.blue3,
},
btnCancel: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
marginHorizontal: 20,
},
})

View file

@ -6,7 +6,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import {usePalette} from 'lib/hooks/usePalette'
import {useModals, useModalControls} from '#/state/modals'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile'
import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel'
@ -66,10 +65,7 @@ export function ModalsContainer() {
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
let element
if (activeModal?.name === 'confirm') {
snapPoints = ConfirmModal.snapPoints
element = <ConfirmModal.Component {...activeModal} />
} else if (activeModal?.name === 'edit-profile') {
if (activeModal?.name === 'edit-profile') {
snapPoints = EditProfileModal.snapPoints
element = <EditProfileModal.Component {...activeModal} />
} else if (activeModal?.name === 'report') {

View file

@ -7,7 +7,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {useModals, useModalControls} from '#/state/modals'
import type {Modal as ModalIface} from '#/state/modals'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile'
import * as ReportModal from './report/Modal'
import * as AppealLabelModal from './AppealLabel'
@ -78,9 +77,7 @@ function Modal({modal}: {modal: ModalIface}) {
}
let element
if (modal.name === 'confirm') {
element = <ConfirmModal.Component {...modal} />
} else if (modal.name === 'edit-profile') {
if (modal.name === 'edit-profile') {
element = <EditProfileModal.Component {...modal} />
} else if (modal.name === 'report') {
element = <ReportModal.Component {...modal} />

View file

@ -9,13 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {msg as msgLingui, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {EmptyState} from '../util/EmptyState'
import {cleanError} from '#/lib/strings/errors'
import {useRemoveFeedMutation} from '#/state/queries/preferences'
import * as Prompt from '#/components/Prompt'
export enum KnownError {
Block = 'Block',
@ -118,35 +118,29 @@ function FeedgenErrorMessage({
)
const [_, uri] = feedDesc.split('|')
const [ownerDid] = safeParseFeedgenUri(uri)
const {openModal, closeModal} = useModalControls()
const removePromptControl = Prompt.usePromptControl()
const {mutateAsync: removeFeed} = useRemoveFeedMutation()
const onViewProfile = React.useCallback(() => {
navigation.navigate('Profile', {name: ownerDid})
}, [navigation, ownerDid])
const onPressRemoveFeed = React.useCallback(() => {
removePromptControl.open()
}, [removePromptControl])
const onRemoveFeed = React.useCallback(async () => {
openModal({
name: 'confirm',
title: _l(msgLingui`Remove feed`),
message: _l(msgLingui`Remove this feed from your saved feeds?`),
async onPressConfirm() {
try {
await removeFeed({uri})
} catch (err) {
Toast.show(
_l(
msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
),
)
logger.error('Failed to remove feed', {message: err})
}
},
onPressCancel() {
closeModal()
},
})
}, [openModal, closeModal, uri, removeFeed, _l])
try {
await removeFeed({uri})
} catch (err) {
Toast.show(
_l(
msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
),
)
logger.error('Failed to remove feed', {message: err})
}
}, [uri, removeFeed, _l])
const cta = React.useMemo(() => {
switch (knownError) {
@ -179,27 +173,38 @@ function FeedgenErrorMessage({
}, [knownError, onViewProfile, onRemoveFeed, _l])
return (
<View
style={[
pal.border,
pal.viewLight,
{
borderTopWidth: 1,
paddingHorizontal: 20,
paddingVertical: 18,
gap: 12,
},
]}>
<Text style={pal.text}>{msg}</Text>
<>
<View
style={[
pal.border,
pal.viewLight,
{
borderTopWidth: 1,
paddingHorizontal: 20,
paddingVertical: 18,
gap: 12,
},
]}>
<Text style={pal.text}>{msg}</Text>
{rawError?.message && (
<Text style={pal.textLight}>
<Trans>Message from server: {rawError.message}</Trans>
</Text>
)}
{rawError?.message && (
<Text style={pal.textLight}>
<Trans>Message from server: {rawError.message}</Trans>
</Text>
)}
{cta}
</View>
{cta}
</View>
<Prompt.Basic
control={removePromptControl}
title={_l(msgLingui`Remove feed?`)}
description={_l(msgLingui`Remove this feed from your saved feeds`)}
onConfirm={onPressRemoveFeed}
confirmButtonCta={_l(msgLingui`Remove`)}
confirmButtonColor="negative"
/>
</>
)
}

View file

@ -7,7 +7,6 @@ import {
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {
AppBskyActorDefs,
ModerationOpts,
@ -17,7 +16,7 @@ import {
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NavigationProp} from 'lib/routes/types'
import {isNative, isWeb} from 'platform/detection'
import {isNative} from 'platform/detection'
import {BlurView} from '../util/BlurView'
import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
@ -28,14 +27,11 @@ import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
import {formatCount} from '../util/numeric/format'
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {Link} from '../util/Link'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
import {useModalControls} from '#/state/modals'
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
import {
RQKEY as profileQueryKey,
useProfileMuteMutationQueue,
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
} from '#/state/queries/profile'
@ -46,9 +42,7 @@ import {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers'
import {toShareUrl} from 'lib/strings/url-helpers'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {shareUrl} from 'lib/sharing'
import {s, colors} from 'lib/styles'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
@ -57,6 +51,8 @@ import {useRequireAuth} from '#/state/session'
import {LabelInfo} from '../util/moderation/LabelInfo'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {atoms as a} from '#/alf'
import {ProfileMenu} from 'view/com/profile/ProfileMenu'
import * as Prompt from '#/components/Prompt'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default')
@ -108,20 +104,13 @@ let ProfileHeader = ({
const {isDesktop} = useWebMediaQueries()
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const queryClient = useQueryClient()
const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
const unblockPromptControl = Prompt.usePromptControl()
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const invalidateProfileQuery = React.useCallback(() => {
queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did),
})
}, [queryClient, profile.did])
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
@ -189,204 +178,23 @@ let ProfileHeader = ({
})
}, [track, openModal, profile])
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 () => {
const unblockAccount = 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')
openModal({
name: 'report',
did: profile.did,
})
}, [track, openModal, profile])
try {
await queueUnblock()
Toast.show(_(msg`Account unblocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [_, queueUnblock, track])
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
const 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) {
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.viewer?.muted,
profile.viewer?.mutedByList,
profile.viewer?.blocking,
profile.viewer?.blockingByList,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
onPressUnblockAccount,
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
_,
])
const blockHide =
!isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
@ -427,7 +235,7 @@ let ProfileHeader = ({
profile.viewer?.blockingByList ? null : (
<TouchableOpacity
testID="unblockBtn"
onPress={onPressUnblockAccount}
onPress={() => unblockPromptControl.open()}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={_(msg`Unblock`)}
@ -516,17 +324,7 @@ let ProfileHeader = ({
)}
</>
) : null}
{dropdownItems?.length ? (
<NativeDropdown
testID="profileHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} />
</View>
</NativeDropdown>
) : undefined}
<ProfileMenu profile={profile} />
</View>
<View pointerEvents="none">
<Text
@ -670,6 +468,18 @@ let ProfileHeader = ({
/>
</View>
</TouchableWithoutFeedback>
<Prompt.Basic
control={unblockPromptControl}
title={_(msg`Unblock Account?`)}
description={_(
msg`The account will be able to interact with you after unblocking.`,
)}
onConfirm={unblockAccount}
confirmButtonCta={
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
}
confirmButtonColor="negative"
/>
</View>
)
}

View file

@ -0,0 +1,315 @@
import React, {memo} from 'react'
import {TouchableOpacity} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useQueryClient} from '@tanstack/react-query'
import * as Toast from 'view/com/util/Toast'
import {EventStopper} from 'view/com/util/EventStopper'
import {useSession} from 'state/session'
import * as Menu from '#/components/Menu'
import {useTheme} from '#/alf'
import {usePalette} from 'lib/hooks/usePalette'
import {HITSLOP_10} from 'lib/constants'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
import {makeProfileLink} from 'lib/routes/links'
import {useAnalytics} from 'lib/analytics/analytics'
import {useModalControls} from 'state/modals'
import {
RQKEY as profileQueryKey,
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
useProfileMuteMutationQueue,
} from 'state/queries/profile'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
import {logger} from '#/logger'
import {Shadow} from 'state/cache/types'
import * as Prompt from '#/components/Prompt'
let ProfileMenu = ({
profile,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}): React.ReactNode => {
const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const t = useTheme()
// TODO ALF this
const pal = usePalette('default')
const {track} = useAnalytics()
const {openModal} = useModalControls()
const queryClient = useQueryClient()
const isSelf = currentAccount?.did === profile.did
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const [, queueUnfollow] = useProfileFollowMutationQueue(profile)
const blockPromptControl = Prompt.usePromptControl()
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 () => {
if (profile.viewer?.muted) {
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()}`))
}
}
} else {
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()}`))
}
}
}
}, [profile.viewer?.muted, track, queueUnmute, _, queueMute])
const blockAccount = React.useCallback(async () => {
if (profile.viewer?.blocking) {
track('ProfileHeader:UnblockAccountButtonClicked')
try {
await queueUnblock()
Toast.show(_(msg`Account unblocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
} else {
track('ProfileHeader:BlockAccountButtonClicked')
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()}`))
}
}
}
}, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock])
const onPressUnfollowAccount = React.useCallback(async () => {
track('ProfileHeader:UnfollowButtonClicked')
try {
await queueUnfollow()
Toast.show(_(msg`Account unfollowed`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [_, queueUnfollow, track])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
openModal({
name: 'report',
did: profile.did,
})
}, [track, openModal, profile])
return (
<EventStopper onKeyDown={false}>
<Menu.Root>
<Menu.Trigger label={_(`More options`)}>
{({props}) => {
return (
<TouchableOpacity
{...props}
hitSlop={HITSLOP_10}
testID="profileHeaderDropdownBtn"
style={[
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
paddingHorizontal: 14,
},
pal.btn,
]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
style={t.atoms.text}
/>
</TouchableOpacity>
)
}}
</Menu.Trigger>
<Menu.Outer style={{minWidth: 170}}>
<Menu.Group>
<Menu.Item
testID="profileHeaderDropdownShareBtn"
label={_(msg`Share`)}
onPress={onPressShare}>
<Menu.ItemText>
<Trans>Share</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Share} />
</Menu.Item>
</Menu.Group>
{hasSession && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to Lists`)}
onPress={onPressAddRemoveLists}>
<Menu.ItemText>
<Trans>Add to Lists</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={List} />
</Menu.Item>
{!isSelf && (
<>
{profile.viewer?.following &&
(profile.viewer.blocking || profile.viewer.blockedBy) && (
<Menu.Item
testID="profileHeaderDropdownUnfollowBtn"
label={_(msg`Unfollow Account`)}
onPress={onPressUnfollowAccount}>
<Menu.ItemText>
<Trans>Unfollow Account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={UserMinus} />
</Menu.Item>
)}
{!profile.viewer?.blocking &&
!profile.viewer?.mutedByList && (
<Menu.Item
testID="profileHeaderDropdownMuteBtn"
label={
profile.viewer?.muted
? _(msg`Unmute Account`)
: _(msg`Mute Account`)
}
onPress={onPressMuteAccount}>
<Menu.ItemText>
{profile.viewer?.muted ? (
<Trans>Unmute Account</Trans>
) : (
<Trans>Mute Account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={profile.viewer?.muted ? Unmute : Mute}
/>
</Menu.Item>
)}
{!profile.viewer?.blockingByList && (
<Menu.Item
testID="profileHeaderDropdownBlockBtn"
label={
profile.viewer
? _(msg`Unblock Account`)
: _(msg`Block Account`)
}
onPress={() => blockPromptControl.open()}>
<Menu.ItemText>
{profile.viewer?.blocking ? (
<Trans>Unblock Account</Trans>
) : (
<Trans>Block Account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={
profile.viewer?.blocking ? PersonCheck : PersonX
}
/>
</Menu.Item>
)}
<Menu.Item
testID="profileHeaderDropdownReportBtn"
label={_(msg`Report Account`)}
onPress={onPressReportAccount}>
<Menu.ItemText>
<Trans>Report Account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</>
)}
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={blockPromptControl}
title={
profile.viewer?.blocking
? _(msg`Unblock Account?`)
: _(msg`Block Account?`)
}
description={
profile.viewer?.blocking
? _(
msg`The account will be able to interact with you after unblocking.`,
)
: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)
}
onConfirm={blockAccount}
confirmButtonCta={
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
}
confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'}
/>
</EventStopper>
)
}
ProfileMenu = memo(ProfileMenu)
export {ProfileMenu}

View file

@ -11,14 +11,9 @@ import {
TouchableWithoutFeedback,
TouchableOpacity,
} from 'react-native'
import {
useLinkProps,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {useLinkProps, StackActions} from '@react-navigation/native'
import {Text} from './text/Text'
import {TypographyVariant} from 'lib/ThemeContext'
import {NavigationProp} from 'lib/routes/types'
import {router} from '../../../routes'
import {
convertBskyAppUrlIfNeeded,
@ -32,6 +27,10 @@ import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
import {useModalControls} from '#/state/modals'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {WebAuxClickWrapper} from 'view/com/util/WebAuxClickWrapper'
import {
DebouncedNavigationProp,
useNavigationDeduped,
} from 'lib/hooks/useNavigationDeduped'
type Event =
| React.MouseEvent<HTMLAnchorElement, MouseEvent>
@ -65,7 +64,7 @@ export const Link = memo(function Link({
...props
}: Props) {
const {closeModal} = useModalControls()
const navigation = useNavigation<NavigationProp>()
const navigation = useNavigationDeduped()
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
const openLink = useOpenLink()
@ -176,7 +175,7 @@ export const TextLink = memo(function TextLink({
navigationAction?: 'push' | 'replace' | 'navigate'
} & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)})
const navigation = useNavigation<NavigationProp>()
const navigation = useNavigationDeduped()
const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink()
@ -335,7 +334,7 @@ const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/']
// -prf
function onPressInner(
closeModal = () => {},
navigation: NavigationProp,
navigation: DebouncedNavigationProp,
href: string,
navigationAction: 'push' | 'replace' | 'navigate' = 'push',
openLink: (href: string) => void,

View file

@ -1,9 +1,13 @@
import React, {memo, useMemo} from 'react'
import {Image, StyleSheet, View} from 'react-native'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import Svg, {Circle, Rect, Path} from 'react-native-svg'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {ModerationUI} from '@atproto/api'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {
usePhotoLibraryPermission,
@ -11,12 +15,16 @@ import {
} from 'lib/hooks/usePermissions'
import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {isWeb, isAndroid, isNative} from 'platform/detection'
import {UserPreviewLink} from './UserPreviewLink'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import * as Menu from '#/components/Menu'
import {
Camera_Stroke2_Corner0_Rounded as Camera,
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
} from '#/components/icons/Camera'
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {useTheme} from '#/alf'
export type UserAvatarType = 'user' | 'algo' | 'list'
@ -196,6 +204,7 @@ let EditableUserAvatar = ({
avatar,
onSelectNewAvatar,
}: EditableUserAvatarProps): React.ReactNode => {
const t = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
@ -216,118 +225,115 @@ let EditableUserAvatar = ({
}
}, [type, size])
const dropdownItems = useMemo(
() =>
[
!isWeb && {
testID: 'changeAvatarCameraBtn',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
},
android: 'ic_menu_camera',
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
const onOpenCamera = React.useCallback(async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewAvatar(
await openCamera({
width: 1000,
height: 1000,
cropperCircleOverlay: true,
}),
)
},
},
{
testID: 'changeAvatarLibraryBtn',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
},
android: 'ic_menu_gallery',
web: 'gallery',
},
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
onSelectNewAvatar(
await openCamera({
width: 1000,
height: 1000,
cropperCircleOverlay: true,
}),
)
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
const items = await openPicker({
aspect: [1, 1],
})
const item = items[0]
if (!item) {
return
}
const onOpenLibrary = React.useCallback(async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const croppedImage = await openCropper({
mediaType: 'photo',
cropperCircleOverlay: true,
height: item.height,
width: item.width,
path: item.path,
})
const items = await openPicker({
aspect: [1, 1],
})
const item = items[0]
if (!item) {
return
}
onSelectNewAvatar(croppedImage)
},
},
!!avatar && {
label: 'separator',
},
!!avatar && {
testID: 'changeAvatarRemoveBtn',
label: _(msg`Remove`),
icon: {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: ['far', 'trash-can'],
},
onPress: async () => {
onSelectNewAvatar(null)
},
},
].filter(Boolean) as DropdownItem[],
[
avatar,
onSelectNewAvatar,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
const croppedImage = await openCropper({
mediaType: 'photo',
cropperCircleOverlay: true,
height: item.height,
width: item.width,
path: item.path,
})
onSelectNewAvatar(croppedImage)
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
const onRemoveAvatar = React.useCallback(() => {
onSelectNewAvatar(null)
}, [onSelectNewAvatar])
return (
<NativeDropdown
testID="changeAvatarBtn"
items={dropdownItems}
accessibilityLabel={_(msg`Image options`)}
accessibilityHint="">
{avatar ? (
<HighPriorityImage
testID="userAvatarImage"
style={aviStyle}
source={{uri: avatar}}
accessibilityRole="image"
/>
) : (
<DefaultAvatar type={type} size={size} />
)}
<View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon
icon="camera"
size={12}
color={pal.text.color as string}
/>
</View>
</NativeDropdown>
<Menu.Root>
<Menu.Trigger label={_(msg`Edit avatar`)}>
{({props}) => (
<TouchableOpacity {...props} activeOpacity={0.8}>
{avatar ? (
<HighPriorityImage
testID="userAvatarImage"
style={aviStyle}
source={{uri: avatar}}
accessibilityRole="image"
/>
) : (
<DefaultAvatar type={type} size={size} />
)}
<View style={[styles.editButtonContainer, pal.btn]}>
<CameraFilled height={14} width={14} style={t.atoms.text} />
</View>
</TouchableOpacity>
)}
</Menu.Trigger>
<Menu.Outer showCancel>
<Menu.Group>
{isNative && (
<Menu.Item
testID="changeAvatarCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeAvatarLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
) : (
<Trans>Upload from Files</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!avatar && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="changeAvatarRemoveBtn"
label={_(`Remove Avatar`)}
onPress={onRemoveAvatar}>
<Menu.ItemText>
<Trans>Remove Avatar</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
)
}
EditableUserAvatar = memo(EditableUserAvatar)

View file

@ -1,21 +1,29 @@
import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {useTheme as useAlfTheme} from '#/alf'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {
usePhotoLibraryPermission,
useCameraPermission,
} from 'lib/hooks/usePermissions'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection'
import {isAndroid, isNative} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
import {EventStopper} from 'view/com/util/EventStopper'
import * as Menu from '#/components/Menu'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
} from '#/components/icons/Camera'
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
export function UserBanner({
banner,
@ -28,118 +36,120 @@ export function UserBanner({
}) {
const pal = usePalette('default')
const theme = useTheme()
const t = useAlfTheme()
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const dropdownItems: DropdownItem[] = useMemo(
() =>
[
!isWeb && {
testID: 'changeBannerCameraBtn',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
},
android: 'ic_menu_camera',
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewBanner?.(
await openCamera({
width: 3000,
height: 1000,
}),
)
},
},
{
testID: 'changeBannerLibraryBtn',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
},
android: 'ic_menu_gallery',
web: 'gallery',
},
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker()
if (!items[0]) {
return
}
const onOpenCamera = React.useCallback(async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewBanner?.(
await openCamera({
width: 3000,
height: 1000,
}),
)
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
onSelectNewBanner?.(
await openCropper({
mediaType: 'photo',
path: items[0].path,
width: 3000,
height: 1000,
}),
)
},
},
!!banner && {
testID: 'changeBannerRemoveBtn',
label: _(msg`Remove`),
icon: {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: ['far', 'trash-can'],
},
onPress: () => {
onSelectNewBanner?.(null)
},
},
].filter(Boolean) as DropdownItem[],
[
banner,
onSelectNewBanner,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
const onOpenLibrary = React.useCallback(async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker()
if (!items[0]) {
return
}
onSelectNewBanner?.(
await openCropper({
mediaType: 'photo',
path: items[0].path,
width: 3000,
height: 1000,
}),
)
}, [onSelectNewBanner, requestPhotoAccessIfNeeded])
const onRemoveBanner = React.useCallback(() => {
onSelectNewBanner?.(null)
}, [onSelectNewBanner])
// setUserBanner is only passed as prop on the EditProfile component
return onSelectNewBanner ? (
<NativeDropdown
testID="changeBannerBtn"
items={dropdownItems}
accessibilityLabel={_(msg`Image options`)}
accessibilityHint="">
{banner ? (
<Image
testID="userBannerImage"
style={styles.bannerImage}
source={{uri: banner}}
accessible={true}
accessibilityIgnoresInvertColors
/>
) : (
<View
testID="userBannerFallback"
style={[styles.bannerImage, styles.defaultBanner]}
/>
)}
<View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon
icon="camera"
size={12}
style={{color: colors.white}}
color={pal.text.color as string}
/>
</View>
</NativeDropdown>
<EventStopper onKeyDown={false}>
<Menu.Root>
<Menu.Trigger label={_(msg`Edit avatar`)}>
{({props}) => (
<TouchableOpacity {...props} activeOpacity={0.8}>
{banner ? (
<Image
testID="userBannerImage"
style={styles.bannerImage}
source={{uri: banner}}
accessible={true}
accessibilityIgnoresInvertColors
/>
) : (
<View
testID="userBannerFallback"
style={[styles.bannerImage, styles.defaultBanner]}
/>
)}
<View style={[styles.editButtonContainer, pal.btn]}>
<CameraFilled height={14} width={14} style={t.atoms.text} />
</View>
</TouchableOpacity>
)}
</Menu.Trigger>
<Menu.Outer showCancel>
<Menu.Group>
{isNative && (
<Menu.Item
testID="changeBannerCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeBannerLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
) : (
<Trans>Upload from Files</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!banner && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="changeBannerRemoveBtn"
label={_(`Remove Banner`)}
onPress={onRemoveBanner}>
<Menu.ItemText>
<Trans>Remove Banner</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
</EventStopper>
) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image

View file

@ -1,11 +1,5 @@
import React, {memo} from 'react'
import {
StyleProp,
ViewStyle,
Pressable,
View,
PressableProps,
} from 'react-native'
import {StyleProp, ViewStyle, Pressable, PressableProps} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
@ -20,6 +14,8 @@ import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing'
import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper'
import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams} from '#/lib/routes/types'
@ -38,7 +34,7 @@ import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {atoms as a, useTheme as useAlf, web} from '#/alf'
import {atoms as a, useTheme as useAlf} from '#/alf'
import * as Menu from '#/components/Menu'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
@ -87,6 +83,8 @@ let PostDropdownBtn = ({
const openLink = useOpenLink()
const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const deletePromptControl = useDialogControl()
const hidePromptControl = useDialogControl()
const rootUri = record.reply?.root?.uri || postUri
const isThreadMuted = mutedThreads.includes(rootUri)
@ -174,29 +172,18 @@ let PostDropdownBtn = ({
<Menu.Root>
<Menu.Trigger label={_(msg`Open post options menu`)}>
{({props, state}) => {
const styles = [
style,
a.rounded_full,
(state.hovered || state.focused || state.pressed) && [
web({outline: 0}),
alf.atoms.bg_contrast_25,
],
]
return isWeb ? (
<View {...props} testID={testID} style={styles}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={defaultCtrlColor}
style={{pointerEvents: 'none'}}
/>
</View>
) : (
return (
<Pressable
{...props}
hitSlop={hitSlop}
testID={testID}
style={styles}>
style={[
style,
a.rounded_full,
(state.hovered || state.pressed) && [
alf.atoms.bg_contrast_50,
],
]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
@ -274,16 +261,7 @@ let PostDropdownBtn = ({
<Menu.Item
testID="postDropdownHideBtn"
label={_(msg`Hide post`)}
onPress={() => {
openModal({
name: 'confirm',
title: _(msg`Hide this post?`),
message: _(
msg`This will hide this post from your feeds.`,
),
onPressConfirm: onHidePost,
})
}}>
onPress={hidePromptControl.open}>
<Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item>
@ -315,14 +293,7 @@ let PostDropdownBtn = ({
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
onPress={() => {
openModal({
name: 'confirm',
title: _(msg`Delete this post?`),
message: _(msg`Are you sure? This cannot be undone.`),
onPressConfirm: onDeletePost,
})
}}>
onPress={deletePromptControl.open}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
@ -352,6 +323,25 @@ let PostDropdownBtn = ({
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={deletePromptControl}
title={_(msg`Delete this post?`)}
description={_(
msg`If you remove this post, you won't be able to recover it.`,
)}
onConfirm={onDeletePost}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
/>
<Prompt.Basic
control={hidePromptControl}
title={_(msg`Hide this post?`)}
description={_(msg`This post will be hidden from feeds.`)}
onConfirm={onHidePost}
confirmButtonCta={_(msg`Hide`)}
/>
</EventStopper>
)
}

View file

@ -1,12 +1,13 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import Animated from 'react-native-reanimated'
import {useMediaQuery} from 'react-responsive'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {colors} from 'lib/styles'
import {HITSLOP_20} from 'lib/constants'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import Animated from 'react-native-reanimated'
const AnimatedTouchableOpacity =
Animated.createAnimatedComponent(TouchableOpacity)
import {isWeb} from 'platform/detection'
@ -26,6 +27,9 @@ export function LoadLatestBtn({
const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries()
const {fabMinimalShellTransform} = useMinimalShellMode()
// move button inline if it starts overlapping the left nav
const isTallViewport = useMediaQuery({minHeight: 700})
// Adjust height of the fab if we have a session only on mobile web. If we don't have a session, we want to adjust
// it on both tablet and mobile since we are showing the bottom bar (see createNativeStackNavigatorWithAuth)
const showBottomBar = hasSession ? isMobile : isTabletOrMobile
@ -34,8 +38,11 @@ export function LoadLatestBtn({
<AnimatedTouchableOpacity
style={[
styles.loadLatest,
isDesktop && styles.loadLatestDesktop,
isTablet && styles.loadLatestTablet,
isDesktop &&
(isTallViewport
? styles.loadLatestOutOfLine
: styles.loadLatestInline),
isTablet && styles.loadLatestInline,
pal.borderDark,
pal.view,
showBottomBar && fabMinimalShellTransform,
@ -65,11 +72,11 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
loadLatestTablet: {
loadLatestInline: {
// @ts-ignore web only
left: 'calc(50vw - 282px)',
},
loadLatestDesktop: {
loadLatestOutOfLine: {
// @ts-ignore web only
left: 'calc(50vw - 382px)',
},