diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml index f7f24af9f7..23d1538fe5 100644 --- a/.github/workflows/build-and-push-embedr-aws.yaml +++ b/.github/workflows/build-and-push-embedr-aws.yaml @@ -3,8 +3,6 @@ on: push: branches: - main - - bnewbold/embedr - - bnewbold/embedr-rebase env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 22cc657353..f0e23263db 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,6 +16,10 @@ jobs: steps: - name: Check out Git repository uses: actions/checkout@v3 + - name: Install node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc - name: Yarn install uses: Wandalen/wretry.action@master with: diff --git a/.nvmrc b/.nvmrc index 3c032078a4..209e3ef4b6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 +20 diff --git a/Dockerfile b/Dockerfile index 0fa0065a10..557321872a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app ENV DEBIAN_FRONTEND=noninteractive # Node -ENV NODE_VERSION=18 +ENV NODE_VERSION=20 ENV NVM_DIR=/usr/share/nvm # Go @@ -17,7 +17,7 @@ ENV GOEXPERIMENT="loopvar" # Expo ARG EXPO_PUBLIC_BUNDLE_IDENTIFIER -ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER ${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-dev} +ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER=${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-dev} COPY . . diff --git a/Dockerfile.embedr b/Dockerfile.embedr index 663cbcfc51..3623b3293e 100644 --- a/Dockerfile.embedr +++ b/Dockerfile.embedr @@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app ENV DEBIAN_FRONTEND=noninteractive # Node -ENV NODE_VERSION=18 +ENV NODE_VERSION=20 ENV NVM_DIR=/usr/share/nvm # Go diff --git a/assets/icons/personPlus_stroke2_corner2_rounded.svg b/assets/icons/personPlus_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..148c29e0f0 --- /dev/null +++ b/assets/icons/personPlus_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyweb/cmd/embedr/server.go b/bskyweb/cmd/embedr/server.go index 904b4df9a2..a9a8ab57f9 100644 --- a/bskyweb/cmd/embedr/server.go +++ b/bskyweb/cmd/embedr/server.go @@ -112,8 +112,8 @@ func serve(cctx *cli.Context) error { Skipper: middleware.DefaultSkipper, Store: middleware.NewRateLimiterMemoryStoreWithConfig( middleware.RateLimiterMemoryStoreConfig{ - Rate: 10, // requests per second - Burst: 30, // allow bursts + Rate: 20, // requests per second + Burst: 150, // allow bursts ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes }, ), diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift index a8b8f0c058..90d0fed0df 100644 --- a/modules/bottom-sheet/ios/SheetViewController.swift +++ b/modules/bottom-sheet/ios/SheetViewController.swift @@ -64,11 +64,14 @@ class SheetViewController: UIViewController { func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) { if let sheet = self.sheetPresentationController { - sheet.animateChanges { - self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion) - if #available(iOS 16.0, *) { - sheet.invalidateDetents() - } + // Capture `self` weakly to prevent retain cycles. + // Also, capture `sheet` weakly to avoid potential strong references held by animateChanges. + sheet.animateChanges { [weak self, weak sheet] in + guard let weakSelf = self, let weakSheet = sheet else { return } + weakSelf.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion) + if #available(iOS 16.0, *) { + weakSheet.invalidateDetents() + } } } } diff --git a/modules/bottom-sheet/src/BottomSheet.tsx b/modules/bottom-sheet/src/BottomSheet.tsx index bcc2c42ad1..8da2773e9f 100644 --- a/modules/bottom-sheet/src/BottomSheet.tsx +++ b/modules/bottom-sheet/src/BottomSheet.tsx @@ -1,24 +1 @@ -import React from 'react' - -import {BottomSheetViewProps} from './BottomSheet.types' -import {BottomSheetNativeComponent} from './BottomSheetNativeComponent' -import {useBottomSheetPortal_INTERNAL} from './BottomSheetPortal' - -export const BottomSheet = React.forwardRef< - BottomSheetNativeComponent, - BottomSheetViewProps ->(function BottomSheet(props, ref) { - const Portal = useBottomSheetPortal_INTERNAL() - - if (__DEV__ && !Portal) { - throw new Error( - 'BottomSheet: You need to wrap your component tree with a to use the bottom sheet.', - ) - } - - return ( - - - - ) -}) +export {BottomSheetNativeComponent as BottomSheet} from './BottomSheetNativeComponent' diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index fa2b163bf0..acd46ce015 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -11,6 +11,7 @@ import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types' import {BottomSheetPortalProvider} from './BottomSheetPortal' +import {Context as PortalContext} from './BottomSheetPortal' const screenHeight = Dimensions.get('screen').height @@ -34,6 +35,8 @@ export class BottomSheetNativeComponent extends React.Component< > { ref = React.createRef() + static contextType = PortalContext + constructor(props: BottomSheetViewProps) { super(props) this.state = { @@ -67,6 +70,17 @@ export class BottomSheetNativeComponent extends React.Component< } render() { + const Portal = this.context as React.ContextType + if (!Portal) { + throw new Error( + 'BottomSheet: You need to wrap your component tree with a to use the bottom sheet.', + ) + } + + if (!this.state.open) { + return null + } + const {children, backgroundColor, ...rest} = this.props const cornerRadius = rest.cornerRadius ?? 0 @@ -83,43 +97,41 @@ export class BottomSheetNativeComponent extends React.Component< } } - if (!this.state.open) { - return null - } - return ( - - + + { - const {height} = e.nativeEvent.layout - this.setState({viewHeight: height}) - this.updateLayout() - }}> - {children} + style={[ + { + flex: 1, + backgroundColor, + }, + Platform.OS === 'android' && { + borderTopLeftRadius: cornerRadius, + borderTopRightRadius: cornerRadius, + }, + extraStyles, + ]}> + { + const {height} = e.nativeEvent.layout + this.setState({viewHeight: height}) + this.updateLayout() + }}> + {children} + - - + + ) } } diff --git a/modules/bottom-sheet/src/BottomSheetPortal.tsx b/modules/bottom-sheet/src/BottomSheetPortal.tsx index da14cfa774..4d8ed57ff0 100644 --- a/modules/bottom-sheet/src/BottomSheetPortal.tsx +++ b/modules/bottom-sheet/src/BottomSheetPortal.tsx @@ -4,7 +4,7 @@ import {createPortalGroup_INTERNAL} from './lib/Portal' type PortalContext = React.ElementType<{children: React.ReactNode}> -const Context = React.createContext({} as PortalContext) +export const Context = React.createContext({} as PortalContext) export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context) diff --git a/package.json b/package.json index 379d21a93f..8525885c70 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.94.0", "private": true, "engines": { - "node": ">=18" + "node": ">=20" }, "packageManager": "yarn@1.22.19", "scripts": { @@ -103,7 +103,7 @@ "@tiptap/suggestion": "^2.6.6", "@types/invariant": "^2.2.37", "@types/lodash.throttle": "^4.1.9", - "@types/node": "^18.16.2", + "@types/node": "^20.14.3", "@zxing/text-encoding": "^0.9.0", "array.prototype.findlast": "^1.2.3", "await-lock": "^2.2.2", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index d8f18200f0..1806df92eb 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -40,14 +40,11 @@ import { shouldRequestEmailConfirmation, snoozeEmailConfirmationPrompt, } from '#/state/shell/reminders' -import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings' -import {AppPasswords} from '#/view/screens/AppPasswords' import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines' import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy' import {DebugModScreen} from '#/view/screens/DebugMod' import {FeedsScreen} from '#/view/screens/Feeds' import {HomeScreen} from '#/view/screens/Home' -import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings' import {ListsScreen} from '#/view/screens/Lists' import {LogScreen} from '#/view/screens/Log' import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts' @@ -56,9 +53,6 @@ import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts' import {NotFoundScreen} from '#/view/screens/NotFound' import {NotificationsScreen} from '#/view/screens/Notifications' import {PostThreadScreen} from '#/view/screens/PostThread' -import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' -import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed' -import {PreferencesThreads} from '#/view/screens/PreferencesThreads' import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' import {ProfileScreen} from '#/view/screens/Profile' import {ProfileFeedScreen} from '#/view/screens/ProfileFeed' @@ -68,7 +62,6 @@ import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows' import {ProfileListScreen} from '#/view/screens/ProfileList' import {SavedFeeds} from '#/view/screens/SavedFeeds' import {SearchScreen} from '#/view/screens/Search' -import {SettingsScreen} from '#/view/screens/Settings' import {Storybook} from '#/view/screens/Storybook' import {SupportScreen} from '#/view/screens/Support' import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' @@ -96,9 +89,16 @@ import {useTheme} from '#/alf' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' import {AboutSettingsScreen} from './screens/Settings/AboutSettings' +import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings' import {AccountSettingsScreen} from './screens/Settings/AccountSettings' +import {AppPasswordsScreen} from './screens/Settings/AppPasswords' import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings' +import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences' +import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences' +import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' +import {SettingsScreen} from './screens/Settings/Settings' +import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences' const navigationRef = createNavigationContainerRef() @@ -285,7 +285,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { /> AppPasswords} + getComponent={() => AppPasswordsScreen} options={{title: title(msg`App Passwords`), requireAuth: true}} /> PreferencesFollowingFeed} + getComponent={() => FollowingFeedPreferencesScreen} options={{ title: title(msg`Following Feed Preferences`), requireAuth: true, @@ -303,12 +303,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { /> PreferencesThreads} + getComponent={() => ThreadPreferencesScreen} options={{title: title(msg`Threads Preferences`), requireAuth: true}} /> PreferencesExternalEmbeds} + getComponent={() => ExternalMediaPreferencesScreen} options={{ title: title(msg`External Media Preferences`), requireAuth: true, diff --git a/src/alf/index.tsx b/src/alf/index.tsx index f9d93d4ca8..5d08722ff4 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -103,35 +103,32 @@ export function ThemeProvider({ }) }, []) - return ( - ( - () => ({ - themes, - themeName: themeName, - theme: themes[themeName], - fonts: { - scale: fontScale, - scaleMultiplier: fontScaleMultiplier, - family: fontFamily, - setFontScale: setFontScaleAndPersist, - setFontFamily: setFontFamilyAndPersist, - }, - flags: {}, - }), - [ - themeName, - themes, - fontScale, - setFontScaleAndPersist, - fontFamily, - setFontFamilyAndPersist, - fontScaleMultiplier, - ], - )}> - {children} - + const value = React.useMemo( + () => ({ + themes, + themeName: themeName, + theme: themes[themeName], + fonts: { + scale: fontScale, + scaleMultiplier: fontScaleMultiplier, + family: fontFamily, + setFontScale: setFontScaleAndPersist, + setFontFamily: setFontFamilyAndPersist, + }, + flags: {}, + }), + [ + themeName, + themes, + fontScale, + setFontScaleAndPersist, + fontFamily, + setFontFamilyAndPersist, + fontScaleMultiplier, + ], ) + + return {children} } export function useAlf() { diff --git a/src/components/AvatarStack.tsx b/src/components/AvatarStack.tsx new file mode 100644 index 0000000000..5f790fb671 --- /dev/null +++ b/src/components/AvatarStack.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {View} from 'react-native' +import {moderateProfile} from '@atproto/api' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useProfilesQuery} from '#/state/queries/profile' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' + +export function AvatarStack({ + profiles, + size = 26, +}: { + profiles: string[] + size?: number +}) { + const halfSize = size / 2 + const {data, error} = useProfilesQuery({handles: profiles}) + const t = useTheme() + const moderationOpts = useModerationOpts() + + if (error) { + console.error(error) + return null + } + + const isPending = !data || !moderationOpts + + const items = isPending + ? Array.from({length: profiles.length}).map((_, i) => ({ + key: i, + profile: null, + moderation: null, + })) + : data.profiles.map(item => ({ + key: item.did, + profile: item, + moderation: moderateProfile(item, moderationOpts), + })) + + return ( + + {items.map((item, i) => ( + + {item.profile && ( + + )} + + ))} + + ) +} diff --git a/src/components/Error.tsx b/src/components/Error.tsx index a27ccb88c6..2819986b34 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -32,7 +32,7 @@ export function Error({ return ( { + if (needsEmailVerification) { + verifyEmailControl.open() + } else { + confirmDialogControl.open() + } + }} style={{backgroundColor: 'transparent'}}> Make one for me @@ -262,7 +273,13 @@ function Empty() { color="primary" size="small" disabled={isGenerating} - onPress={() => navigation.navigate('StarterPackWizard')} + onPress={() => { + if (needsEmailVerification) { + verifyEmailControl.open() + } else { + navigation.navigate('StarterPackWizard') + } + }} style={{ backgroundColor: 'white', borderColor: 'white', @@ -318,6 +335,12 @@ function Empty() { onConfirm={generate} confirmButtonCta={_(msg`Retry`)} /> + ) } diff --git a/src/components/SubtleWebHover.tsx b/src/components/SubtleWebHover.tsx index e6f427237b..5cbbfc898a 100644 --- a/src/components/SubtleWebHover.tsx +++ b/src/components/SubtleWebHover.tsx @@ -1,3 +1,5 @@ -export function SubtleWebHover({}: {hover: boolean}) { +import {ViewStyleProp} from '#/alf' + +export function SubtleWebHover({}: ViewStyleProp & {hover: boolean}) { return null } diff --git a/src/components/SubtleWebHover.web.tsx b/src/components/SubtleWebHover.web.tsx index e98251e0dd..adabf46bfd 100644 --- a/src/components/SubtleWebHover.web.tsx +++ b/src/components/SubtleWebHover.web.tsx @@ -2,9 +2,12 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {isTouchDevice} from '#/lib/browser' -import {useTheme} from '#/alf' +import {useTheme, ViewStyleProp} from '#/alf' -export function SubtleWebHover({hover}: {hover: boolean}) { +export function SubtleWebHover({ + style, + hover, +}: ViewStyleProp & {hover: boolean}) { const t = useTheme() if (isTouchDevice) { return null @@ -26,9 +29,8 @@ export function SubtleWebHover({hover}: {hover: boolean}) { style={[ t.atoms.bg_contrast_25, styles.container, - { - opacity: hover ? opacity : 0, - }, + {opacity: hover ? opacity : 0}, + style, ]} /> ) diff --git a/src/components/dialogs/VerifyEmailDialog.tsx b/src/components/dialogs/VerifyEmailDialog.tsx index 8dfb9bc490..d4412b6f89 100644 --- a/src/components/dialogs/VerifyEmailDialog.tsx +++ b/src/components/dialogs/VerifyEmailDialog.tsx @@ -18,8 +18,14 @@ import {Text} from '#/components/Typography' export function VerifyEmailDialog({ control, + onCloseWithoutVerifying, + onCloseAfterVerifying, + reasonText, }: { control: Dialog.DialogControlProps + onCloseWithoutVerifying?: () => void + onCloseAfterVerifying?: () => void + reasonText?: string }) { const agent = useAgent() @@ -30,18 +36,24 @@ export function VerifyEmailDialog({ control={control} onClose={async () => { if (!didVerify) { + onCloseWithoutVerifying?.() return } try { await agent.resumeSession(agent.session!) + onCloseAfterVerifying?.() } catch (e: unknown) { logger.error(String(e)) return } }}> - + ) } @@ -49,9 +61,11 @@ export function VerifyEmailDialog({ export function Inner({ control, setDidVerify, + reasonText, }: { control: Dialog.DialogControlProps setDidVerify: (value: boolean) => void + reasonText?: string }) { const {_} = useLingui() const {currentAccount} = useSession() @@ -135,26 +149,32 @@ export function Inner({ {currentStep === 'StepOne' ? ( <> - - You'll receive an email at{' '} - - {currentAccount?.email} - {' '} - to verify it's you. - {' '} - { - e.preventDefault() - control.close(() => { - openModal({name: 'change-email'}) - }) - return false - }}> - Need to change it? - + {!reasonText ? ( + <> + + You'll receive an email at{' '} + + {currentAccount?.email} + {' '} + to verify it's you. + {' '} + { + e.preventDefault() + control.close(() => { + openModal({name: 'change-email'}) + }) + return false + }}> + Need to change it? + + + ) : ( + reasonText + )} ) : ( uiStrings[currentStep].message diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 9b06bd0b20..b77516e7b7 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -53,9 +53,11 @@ export function ActionsWrapper({ .numberOfTaps(2) .hitSlop(HITSLOP_10) .onEnd(open) + .runOnJS(true) const pressAndHoldGesture = Gesture.LongPress() .onStart(() => { + 'worklet' scale.value = withTiming(1.05, {duration: 200}, finished => { if (!finished) return runOnJS(open)() @@ -65,7 +67,6 @@ export function ActionsWrapper({ .onTouchesUp(shrink) .onTouchesMove(shrink) .cancelsTouchesInView(false) - .runOnJS(true) const composedGestures = Gesture.Exclusive( doubleTapGesture, diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 932982d05e..22936b4c06 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -3,14 +3,18 @@ import {View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {useEmail} from '#/lib/hooks/useEmail' +import {NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' import {atoms as a, useTheme} from '#/alf' -import {ButtonIcon} from '#/components/Button' +import {Button, ButtonIcon} from '#/components/Button' import {canBeMessaged} from '#/components/dms/util' import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' -import {Link} from '#/components/Link' +import {useDialogControl} from '../Dialog' +import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog' export function MessageProfileButton({ profile, @@ -19,15 +23,29 @@ export function MessageProfileButton({ }) { const {_} = useLingui() const t = useTheme() + const navigation = useNavigation() + const {needsEmailVerification} = useEmail() + const verifyEmailControl = useDialogControl() const {data: convo, isPending} = useMaybeConvoForUser(profile.did) const onPress = React.useCallback(() => { + if (!convo?.id) { + return + } + + if (needsEmailVerification) { + verifyEmailControl.open() + return + } + if (convo && !convo.lastMessage) { logEvent('chat:create', {logContext: 'ProfileHeader'}) } logEvent('chat:open', {logContext: 'ProfileHeader'}) - }, [convo]) + + navigation.navigate('MessagesConversation', {conversation: convo.id}) + }, [needsEmailVerification, verifyEmailControl, convo, navigation]) if (isPending) { // show pending state based on declaration @@ -53,18 +71,26 @@ export function MessageProfileButton({ if (convo) { return ( - - - + <> + + + ) } else { return null diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index ab9ec16e4d..6c3bbf2161 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -147,7 +147,7 @@ function HeaderReady({ const isDeletedAccount = profile?.handle === 'missing.invalid' const displayName = isDeletedAccount - ? 'Deleted Account' + ? _(msg`Deleted Account`) : sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index e80fef2d7e..f402201a21 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -2,6 +2,7 @@ import React, {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useEmail} from '#/lib/hooks/useEmail' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' @@ -9,6 +10,8 @@ import {FAB} from '#/view/com/util/fab/FAB' import * as Toast from '#/view/com/util/Toast' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SearchablePeopleList} from './SearchablePeopleList' @@ -21,6 +24,8 @@ export function NewChat({ }) { const t = useTheme() const {_} = useLingui() + const {needsEmailVerification} = useEmail() + const verifyEmailControl = useDialogControl() const {mutate: createChat} = useGetConvoForMembers({ onSuccess: data => { @@ -48,7 +53,13 @@ export function NewChat({ <> { + if (needsEmailVerification) { + verifyEmailControl.open() + } else { + control.open() + } + }} icon={} accessibilityRole="button" accessibilityLabel={_(msg`New chat`)} @@ -62,6 +73,13 @@ export function NewChat({ onSelectChat={onCreateChat} /> + + ) } diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx index 31d7078d9c..8428fcce1b 100644 --- a/src/components/icons/Person.tsx +++ b/src/components/icons/Person.tsx @@ -24,6 +24,10 @@ export const PersonPlus_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z', }) +export const PersonPlus_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 14c-2.95 0-5.163 1.733-6.08 4.21a.47.47 0 0 0 .09.493.9.9 0 0 0 .687.297H11a1 1 0 1 1 0 2H6.697a2.9 2.9 0 0 1-2.219-1.011 2.46 2.46 0 0 1-.433-2.473C5.235 14.296 8.168 12 12 12c.787 0 1.54.097 2.252.282a1 1 0 1 1-.504 1.936A7 7 0 0 0 12 14Zm6 0a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z', +}) + export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({ path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z', }) diff --git a/src/lib/hooks/useCustomPalette.ts b/src/lib/hooks/useCustomPalette.ts deleted file mode 100644 index 5691ea79b5..0000000000 --- a/src/lib/hooks/useCustomPalette.ts +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -import {choose} from '#/lib/functions' -import {useTheme} from '#/lib/ThemeContext' - -export function useCustomPalette({light, dark}: {light: T; dark: T}) { - const theme = useTheme() - return React.useMemo(() => { - return choose>(theme.colorScheme, { - dark, - light, - }) - }, [theme.colorScheme, dark, light]) -} diff --git a/src/lib/hooks/useEmail.ts b/src/lib/hooks/useEmail.ts new file mode 100644 index 0000000000..6e52846d12 --- /dev/null +++ b/src/lib/hooks/useEmail.ts @@ -0,0 +1,19 @@ +import {useServiceConfigQuery} from '#/state/queries/email-verification-required' +import {useSession} from '#/state/session' +import {BSKY_SERVICE} from '../constants' +import {getHostnameFromUrl} from '../strings/url-helpers' + +export function useEmail() { + const {currentAccount} = useSession() + + const {data: serviceConfig} = useServiceConfigQuery() + + const isSelfHost = + serviceConfig?.checkEmailConfirmed && + currentAccount && + getHostnameFromUrl(currentAccount.service) !== + getHostnameFromUrl(BSKY_SERVICE) + const needsEmailVerification = !isSelfHost && !currentAccount?.emailConfirmed + + return {needsEmailVerification} +} diff --git a/src/lib/media/image-sizes.ts b/src/lib/media/image-sizes.ts deleted file mode 100644 index 8eaa9467f9..0000000000 --- a/src/lib/media/image-sizes.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {useEffect, useState} from 'react' -import {Image} from 'react-native' - -import type {Dimensions} from '#/lib/media/types' - -type CacheStorageItem = {key: string; value: T} -const createCache = (cacheSize: number) => ({ - _storage: [] as CacheStorageItem[], - get(key: string) { - const {value} = - this._storage.find(({key: storageKey}) => storageKey === key) || {} - return value - }, - set(key: string, value: T) { - if (this._storage.length >= cacheSize) { - this._storage.shift() - } - this._storage.push({key, value}) - }, -}) - -const sizes = createCache(50) -const activeRequests: Map> = new Map() - -export function get(uri: string): Dimensions | undefined { - return sizes.get(uri) -} - -export function fetch(uri: string): Promise { - const dims = sizes.get(uri) - if (dims) { - return Promise.resolve(dims) - } - const activeRequest = activeRequests.get(uri) - if (activeRequest) { - return activeRequest - } - const prom = new Promise((resolve, reject) => { - Image.getSize( - uri, - (width: number, height: number) => { - const size = {width, height} - sizes.set(uri, size) - resolve(size) - }, - (err: any) => { - console.error('Failed to fetch image dimensions for', uri, err) - reject(new Error('Could not fetch dimensions')) - }, - ) - }).finally(() => { - activeRequests.delete(uri) - }) - activeRequests.set(uri, prom) - return prom -} - -export function useImageDimensions({ - src, - knownDimensions, -}: { - src: string - knownDimensions: Dimensions | null -}): [number | undefined, Dimensions | undefined] { - const [dims, setDims] = useState(() => knownDimensions ?? get(src)) - const [prevSrc, setPrevSrc] = useState(src) - if (src !== prevSrc) { - setDims(knownDimensions ?? get(src)) - setPrevSrc(src) - } - - useEffect(() => { - let aborted = false - if (dims !== undefined) return - fetch(src).then(newDims => { - if (aborted) return - setDims(newDims) - }) - return () => { - aborted = true - } - }, [dims, setDims, src]) - - let aspectRatio: number | undefined - if (dims) { - aspectRatio = dims.width / dims.height - if (Number.isNaN(aspectRatio)) { - aspectRatio = undefined - } - } - - return [aspectRatio, dims] -} diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 651915738a..ee09adaf0e 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -4,10 +4,11 @@ import {useKeyboardController} from 'react-native-keyboard-controller' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {useEmail} from '#/lib/hooks/useEmail' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {isWeb} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' @@ -19,6 +20,8 @@ import {useSetMinimalShellMode} from '#/state/shell' import {CenteredView} from '#/view/com/util/Views' import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' import {MessagesListHeader} from '#/components/dms/MessagesListHeader' import {Error} from '#/components/Error' @@ -127,9 +130,7 @@ function Inner() { setHasScrolled={setHasScrolled} /> ) : ( - <> - - + )} {!readyToShow && ( > }) { + const {_} = useLingui() const convoState = useConvo() + const navigation = useNavigation() const recipient = useProfileShadow(recipientUnshadowed) + const verifyEmailControl = useDialogControl() + const {needsEmailVerification} = useEmail() const moderation = React.useMemo(() => { return moderateProfile(recipient, moderationOpts) @@ -181,6 +186,12 @@ function InnerReady({ } }, [moderation]) + React.useEffect(() => { + if (needsEmailVerification) { + verifyEmailControl.open() + } + }, [needsEmailVerification, verifyEmailControl]) + return ( <> )} + { + navigation.navigate('Home') + }} + /> ) } diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index bb9c1cd4cb..6b8deea30e 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -104,7 +104,7 @@ function ChatListItemReady({ const isDeletedAccount = profile.handle === 'missing.invalid' const displayName = isDeletedAccount - ? 'Deleted Account' + ? _(msg`Deleted Account`) : sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index 21d6e574ea..8edad6272e 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -18,6 +18,7 @@ import Graphemer from 'graphemer' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' +import {useEmail} from '#/lib/hooks/useEmail' import {isIOS} from '#/platform/detection' import { useMessageDraft, @@ -61,10 +62,15 @@ export function MessageInput({ const [message, setMessage] = React.useState(getDraft) const inputRef = useAnimatedRef() + const {needsEmailVerification} = useEmail() + useSaveMessageDraft(message) useExtractEmbedFromFacets(message, setEmbed) const onSubmit = React.useCallback(() => { + if (needsEmailVerification) { + return + } if (!hasEmbed && message.trim() === '') { return } @@ -84,6 +90,7 @@ export function MessageInput({ inputRef.current?.focus() }, 100) }, [ + needsEmailVerification, hasEmbed, message, clearDraft, @@ -159,6 +166,7 @@ export function MessageInput({ ref={inputRef} hitSlop={HITSLOP_10} animatedProps={animatedProps} + editable={!needsEmailVerification} /> + onPress={onSubmit} + disabled={needsEmailVerification}> diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index f549b8560f..9db4f07b60 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -202,9 +202,9 @@ export function MessagesList({ convoState.items.length, // these are stable flatListRef, - isAtTop.value, - isAtBottom.value, - layoutHeight.value, + isAtTop, + isAtBottom, + layoutHeight, ], ) @@ -212,7 +212,7 @@ export function MessagesList({ if (hasScrolled && prevContentHeight.current > layoutHeight.value) { convoState.fetchMessageHistory() } - }, [convoState, hasScrolled, layoutHeight.value]) + }, [convoState, hasScrolled, layoutHeight]) const onScroll = React.useCallback( (e: ReanimatedScrollEvent) => { @@ -374,7 +374,7 @@ export function MessagesList({ }, [ flatListRef, - keyboardIsOpening.value, + keyboardIsOpening, layoutScrollWithoutAnimation, layoutHeight, ], diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index d5a2daffdf..5f340cd560 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -1,13 +1,11 @@ import React from 'react' import {Linking, View} from 'react-native' import {useSafeAreaFrame} from 'react-native-safe-area-context' -import {ComAtprotoLabelDefs} from '@atproto/api' import {LABELS} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' -import {IS_INTERNAL} from '#/lib/app-info' import {getLabelingServiceTitle} from '#/lib/moderation' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {logger} from '#/logger' @@ -18,11 +16,6 @@ import { UsePreferencesQueryResponse, usePreferencesSetAdultContentMutation, } from '#/state/queries/preferences' -import { - useProfileQuery, - useProfileUpdateMutation, -} from '#/state/queries/profile' -import {useSession} from '#/state/session' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useSetMinimalShellMode} from '#/state/shell' import {ViewHeader} from '#/view/com/util/ViewHeader' @@ -469,131 +462,7 @@ export function ModerationScreenInner({ })} )} - - {!IS_INTERNAL && ( - <> - - Logged-out visibility - - - - - )} - ) } - -function PwiOptOut() { - const t = useTheme() - const {_} = useLingui() - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const updateProfile = useProfileUpdateMutation() - - const isOptedOut = - profile?.labels?.some(l => l.val === '!no-unauthenticated') || false - const canToggle = profile && !updateProfile.isPending - - const onToggleOptOut = React.useCallback(() => { - if (!profile) { - return - } - let wasAdded = false - updateProfile.mutate({ - profile, - updates: existing => { - // create labels attr if needed - existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) - ? existing.labels - : { - $type: 'com.atproto.label.defs#selfLabels', - values: [], - } - - // toggle the label - const hasLabel = existing.labels.values.some( - l => l.val === '!no-unauthenticated', - ) - if (hasLabel) { - wasAdded = false - existing.labels.values = existing.labels.values.filter( - l => l.val !== '!no-unauthenticated', - ) - } else { - wasAdded = true - existing.labels.values.push({val: '!no-unauthenticated'}) - } - - // delete if no longer needed - if (existing.labels.values.length === 0) { - delete existing.labels - } - return existing - }, - checkCommitted: res => { - const exists = !!res.data.labels?.some( - l => l.val === '!no-unauthenticated', - ) - return exists === wasAdded - }, - }) - }, [updateProfile, profile]) - - return ( - - - - - - - Discourage apps from showing my account to logged-out users - - - - - {updateProfile.isPending && } - - - - - - Bluesky will not show your profile and posts to logged-out users. - Other apps may not honor this request. This does not make your - account private. - - - - - Note: Bluesky is an open and public network. This setting only - limits the visibility of your content on the Bluesky app and - website, and other apps may not respect this setting. Your content - may still be shown to logged-out users by other apps and websites. - - - - - Learn more about what is public on Bluesky. - - - - ) -} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 925066d72e..1a1e7d4a24 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,5 +1,12 @@ import React, {memo} from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' +import Animated, { + measure, + MeasuredDimensions, + runOnJS, + runOnUI, + useAnimatedRef, +} from 'react-native-reanimated' import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' @@ -42,6 +49,7 @@ let ProfileHeaderShell = ({ const {openLightbox} = useLightboxControls() const navigation = useNavigation() const {isDesktop} = useWebMediaQueries() + const aviRef = useAnimatedRef() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -51,26 +59,40 @@ let ProfileHeaderShell = ({ } }, [navigation]) - const onPressAvi = React.useCallback(() => { - const modui = moderation.ui('avatar') - if (profile.avatar && !(modui.blur && modui.noOverride)) { + const _openLightbox = React.useCallback( + (uri: string, thumbRect: MeasuredDimensions | null) => { openLightbox({ images: [ { - uri: profile.avatar, - thumbUri: profile.avatar, + uri, + thumbUri: uri, + thumbRect, dimensions: { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, width: 1000, }, + thumbDimensions: null, + type: 'circle-avi', }, ], index: 0, - thumbDims: null, }) + }, + [openLightbox], + ) + + const onPressAvi = React.useCallback(() => { + const modui = moderation.ui('avatar') + const avatar = profile.avatar + if (avatar && !(modui.blur && modui.noOverride)) { + runOnUI(() => { + 'worklet' + const rect = measure(aviRef) + runOnJS(_openLightbox)(avatar, rect) + })() } - }, [openLightbox, profile, moderation]) + }, [profile, moderation, _openLightbox, aviRef]) const isMe = React.useMemo( () => currentAccount?.did === profile.did, @@ -148,12 +170,14 @@ let ProfileHeaderShell = ({ styles.avi, profile.associated?.labeler && styles.aviLabeler, ]}> - + + + diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index f34810a68c..35c5f3aa09 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -6,7 +6,6 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from '#/lib/routes/types' import {useModalControls} from '#/state/modals' import {useSession} from '#/state/session' -import {ExportCarDialog} from '#/view/screens/Settings/ExportCarDialog' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' @@ -24,6 +23,7 @@ import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/ico import * as Layout from '#/components/Layout' import {ChangeHandleDialog} from './components/ChangeHandleDialog' import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' +import {ExportCarDialog} from './components/ExportCarDialog' type Props = NativeStackScreenProps export function AccountSettingsScreen({}: Props) { diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 2062fe9180..adea42e5ed 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -1,6 +1,7 @@ import React, {useState} from 'react' -import {LayoutAnimation, View} from 'react-native' +import {LayoutAnimation, Pressable, View} from 'react-native' import {Linking} from 'react-native' +import {useReducedMotion} from 'react-native-reanimated' import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -9,13 +10,15 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack' import {IS_INTERNAL} from '#/lib/app-info' import {HELP_DESK_URL} from '#/lib/constants' +import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' import {clearStorage} from '#/state/persisted' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' -import {useSession, useSessionApi} from '#/state/session' +import {SessionAccount, useSession, useSessionApi} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' @@ -24,42 +27,50 @@ import {UserAvatar} from '#/view/com/util/UserAvatar' import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName' import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' import * as SettingsList from '#/screens/Settings/components/SettingsList' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' +import {AvatarStack} from '#/components/AvatarStack' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' +import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' import { Person_Stroke2_Corner2_Rounded as PersonIcon, PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon, + PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon, + PersonX_Stroke2_Corner0_Rounded as PersonXIcon, } from '#/components/icons/Person' import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' import * as Prompt from '#/components/Prompt' type Props = NativeStackScreenProps export function SettingsScreen({}: Props) { const {_} = useLingui() + const reducedMotion = useReducedMotion() const {logoutEveryAccount} = useSessionApi() const {accounts, currentAccount} = useSession() const switchAccountControl = useDialogControl() const signOutPromptControl = Prompt.usePromptControl() const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const {setShowLoggedOut} = useLoggedOutViewControls() - const closeEverything = useCloseAllActiveElements() + const {data: otherProfiles} = useProfilesQuery({ + handles: accounts + .filter(acc => acc.did !== currentAccount?.did) + .map(acc => acc.handle), + }) + const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() + const [showAccounts, setShowAccounts] = useState(false) const [showDevOptions, setShowDevOptions] = useState(false) - const onAddAnotherAccount = () => { - setShowLoggedOut(true) - closeEverything() - } - return ( @@ -77,34 +88,59 @@ export function SettingsScreen({}: Props) { ]}> {profile && } - 1 - ? _(msg`Switch account`) - : _(msg`Add another account`) - } - onPress={() => - accounts.length > 1 - ? switchAccountControl.open() - : onAddAnotherAccount() - }> - - - {accounts.length > 1 ? ( - Switch account - ) : ( - Add another account + {accounts.length > 1 ? ( + <> + { + if (!reducedMotion) { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ) + } + setShowAccounts(s => !s) + }}> + + + Switch account + + {showAccounts ? ( + + ) : ( + acc.did) + .filter(did => did !== currentAccount?.did) + .slice(0, 5)} + /> + )} + + {showAccounts && ( + <> + + {accounts + .filter(acc => acc.did !== currentAccount?.did) + .map(account => ( + p.did === account.did, + )} + pendingDid={pendingDid} + onPressSwitchAccount={onPressSwitchAccount} + /> + ))} + + )} - - {accounts.length > 1 && ( - acc.did) - .filter(did => did !== currentAccount?.did) - .slice(0, 5)} - /> - )} - + + ) : ( + + )} @@ -188,9 +224,11 @@ export function SettingsScreen({}: Props) { { - LayoutAnimation.configureNext( - LayoutAnimation.Presets.easeInEaseOut, - ) + if (!reducedMotion) { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ) + } setShowDevOptions(d => !d) }} label={_(msg`Developer options`)}> @@ -245,70 +283,6 @@ function ProfilePreview({ ) } -const AVI_SIZE = 26 -const HALF_AVI_SIZE = AVI_SIZE / 2 - -function AvatarStack({profiles}: {profiles: string[]}) { - const {data, error} = useProfilesQuery({handles: profiles}) - const t = useTheme() - const moderationOpts = useModerationOpts() - - if (error) { - console.error(error) - return null - } - - const isPending = !data || !moderationOpts - - const items = isPending - ? Array.from({length: profiles.length}).map((_, i) => ({ - key: i, - profile: null, - moderation: null, - })) - : data.profiles.map(item => ({ - key: item.did, - profile: item, - moderation: moderateProfile(item, moderationOpts), - })) - - return ( - - {items.map((item, i) => ( - - {item.profile && ( - - )} - - ))} - - ) -} - function DevOptions() { const {_} = useLingui() const onboardingDispatch = useOnboardingDispatch() @@ -373,3 +347,117 @@ function DevOptions() { ) } + +function AddAccountRow() { + const {_} = useLingui() + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeEverything = useCloseAllActiveElements() + + const onAddAnotherAccount = () => { + setShowLoggedOut(true) + closeEverything() + } + + return ( + + + + Add another account + + + ) +} + +function AccountRow({ + profile, + account, + pendingDid, + onPressSwitchAccount, +}: { + profile?: AppBskyActorDefs.ProfileViewDetailed + account: SessionAccount + pendingDid: string | null + onPressSwitchAccount: ( + account: SessionAccount, + logContext: 'Settings', + ) => void +}) { + const {_} = useLingui() + const t = useTheme() + + const moderationOpts = useModerationOpts() + const removePromptControl = Prompt.usePromptControl() + const {removeAccount} = useSessionApi() + + const onSwitchAccount = () => { + if (pendingDid) return + onPressSwitchAccount(account, 'Settings') + } + + return ( + + + {moderationOpts && profile ? ( + + ) : ( + + )} + + {sanitizeHandle(account.handle, '@')} + + {pendingDid === account.did && } + + {!pendingDid && ( + + + {({props, state}) => ( + + + + )} + + + removePromptControl.open()}> + + Remove account + + + + + + )} + + { + removeAccount(account) + Toast.show(_(msg`Account removed from quick access`)) + }} + confirmButtonCta={_(msg`Remove`)} + confirmButtonColor="negative" + /> + + ) +} diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index 96c0f9a117..24dd91bf6a 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -111,7 +111,7 @@ export function ThreadPreferencesScreen({}: Props) { style={[a.w_full, a.gap_md]}> - Show replies by people you follow before all other replies. + Show replies by people you follow before all other replies diff --git a/src/screens/Settings/components/CopyButton.tsx b/src/screens/Settings/components/CopyButton.tsx index eb538f5dee..8c6cdfa8ad 100644 --- a/src/screens/Settings/components/CopyButton.tsx +++ b/src/screens/Settings/components/CopyButton.tsx @@ -1,6 +1,10 @@ import React, {useCallback, useEffect, useState} from 'react' import {GestureResponderEvent, View} from 'react-native' -import Animated, {FadeOutUp, ZoomIn} from 'react-native-reanimated' +import Animated, { + FadeOutUp, + useReducedMotion, + ZoomIn, +} from 'react-native-reanimated' import * as Clipboard from 'expo-clipboard' import {Trans} from '@lingui/macro' @@ -16,13 +20,17 @@ export function CopyButton({ }: ButtonProps & {value: string}) { const [hasBeenCopied, setHasBeenCopied] = useState(false) const t = useTheme() + const isReducedMotionEnabled = useReducedMotion() useEffect(() => { if (hasBeenCopied) { - const timeout = setTimeout(() => setHasBeenCopied(false), 100) + const timeout = setTimeout( + () => setHasBeenCopied(false), + isReducedMotionEnabled ? 2000 : 100, + ) return () => clearTimeout(timeout) } - }, [hasBeenCopied]) + }, [hasBeenCopied, isReducedMotionEnabled]) const onPress = useCallback( (evt: GestureResponderEvent) => { diff --git a/src/view/screens/Settings/DisableEmail2FADialog.tsx b/src/screens/Settings/components/DisableEmail2FADialog.tsx similarity index 100% rename from src/view/screens/Settings/DisableEmail2FADialog.tsx rename to src/screens/Settings/components/DisableEmail2FADialog.tsx diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx index 85ae89deaa..a74f9fce71 100644 --- a/src/screens/Settings/components/Email2FAToggle.tsx +++ b/src/screens/Settings/components/Email2FAToggle.tsx @@ -4,9 +4,9 @@ import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' import {useAgent, useSession} from '#/state/session' -import {DisableEmail2FADialog} from '#/view/screens/Settings/DisableEmail2FADialog' import {useDialogControl} from '#/components/Dialog' import * as Prompt from '#/components/Prompt' +import {DisableEmail2FADialog} from './DisableEmail2FADialog' import * as SettingsList from './SettingsList' export function Email2FAToggle() { diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/screens/Settings/components/ExportCarDialog.tsx similarity index 100% rename from src/view/screens/Settings/ExportCarDialog.tsx rename to src/screens/Settings/components/ExportCarDialog.tsx diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx index 06541106e7..67a450991d 100644 --- a/src/state/lightbox.tsx +++ b/src/state/lightbox.tsx @@ -1,5 +1,4 @@ import React from 'react' -import type {MeasuredDimensions} from 'react-native-reanimated' import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -8,7 +7,6 @@ import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types' export type Lightbox = { id: string images: ImageSource[] - thumbDims: MeasuredDimensions | null index: number } diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 78f476d526..483de99e49 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -48,11 +48,6 @@ export interface DeleteAccountModal { name: 'delete-account' } -export interface ChangeHandleModal { - name: 'change-handle' - onChanged: () => void -} - export interface WaitlistModal { name: 'waitlist' } @@ -61,10 +56,6 @@ export interface InviteCodesModal { name: 'invite-codes' } -export interface AddAppPasswordModal { - name: 'add-app-password' -} - export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } @@ -101,8 +92,6 @@ export interface InAppBrowserConsentModal { export type Modal = // Account - | AddAppPasswordModal - | ChangeHandleModal | DeleteAccountModal | VerifyEmailModal | ChangeEmailModal diff --git a/src/state/queries/email-verification-required.ts b/src/state/queries/email-verification-required.ts new file mode 100644 index 0000000000..94ff5cbc68 --- /dev/null +++ b/src/state/queries/email-verification-required.ts @@ -0,0 +1,25 @@ +import {useQuery} from '@tanstack/react-query' + +interface ServiceConfig { + checkEmailConfirmed: boolean +} + +export function useServiceConfigQuery() { + return useQuery({ + queryKey: ['service-config'], + queryFn: async () => { + const res = await fetch( + 'https://api.bsky.app/xrpc/app.bsky.unspecced.getConfig', + ) + if (!res.ok) { + return { + checkEmailConfirmed: false, + } + } + + const json = await res.json() + return json as ServiceConfig + }, + staleTime: 5 * 60 * 1000, + }) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 1899966dcb..a581cb79e2 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -58,6 +58,7 @@ import {EmbeddingDisabledError} from '#/lib/api/resolve' import {until} from '#/lib/async/until' import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {useEmail} from '#/lib/hooks/useEmail' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' @@ -110,6 +111,8 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -297,6 +300,15 @@ export const ComposePost = ({ } }, [onPressCancel, closeAllDialogs, closeAllModals]) + const {needsEmailVerification} = useEmail() + const emailVerificationControl = useDialogControl() + + useEffect(() => { + if (needsEmailVerification) { + emailVerificationControl.open() + } + }, [needsEmailVerification, emailVerificationControl]) + const missingAltError = useMemo(() => { if (!requireAltTextEnabled) { return @@ -570,6 +582,15 @@ export const ComposePost = ({ const isWebFooterSticky = !isNative && thread.posts.length > 1 return ( + { + onClose() + }} + reasonText={_( + msg`Before creating a post, you must first verify your email.`, + )} + /> - + Suggestive - + Nudity - + Porn diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 42d9bfc549..10cf1a931b 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -252,7 +252,11 @@ export const TextInput = forwardRef(function TextInputImpl( style={[ inputTextStyle, a.w_full, - {textAlignVertical: 'top', minHeight: 60}, + { + textAlignVertical: 'top', + minHeight: 60, + includeFontPadding: false, + }, ]} {...props}> {textDecorated} diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 3276cf8821..707aad7fb0 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -162,7 +162,10 @@ export function FeedSourceCardLoaded({ style={[ pal.border, { - borderTopWidth: showMinimalPlaceholder || hideTopBorder ? 0 : 1, + borderTopWidth: + showMinimalPlaceholder || hideTopBorder + ? 0 + : StyleSheet.hairlineWidth, flexDirection: 'row', alignItems: 'center', flex: 1, diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts index f5ab8bba9a..779b95bfc6 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -6,6 +6,9 @@ * */ +import {TransformsStyle} from 'react-native' +import {MeasuredDimensions} from 'react-native-reanimated' + export type Dimensions = { width: number height: number @@ -18,7 +21,15 @@ export type Position = { export type ImageSource = { uri: string - thumbUri: string - alt?: string dimensions: Dimensions | null + thumbUri: string + thumbDimensions: Dimensions | null + thumbRect: MeasuredDimensions | null + alt?: string + type: 'image' | 'circle-avi' | 'rect-avi' } + +export type Transform = Exclude< + TransformsStyle['transform'], + string | undefined +> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index ed6020000a..260787d2f3 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,21 +1,26 @@ import React, {useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import {ActivityIndicator, StyleSheet} from 'react-native' +import { + Gesture, + GestureDetector, + PanGesture, +} from 'react-native-gesture-handler' import Animated, { - AnimatedRef, - measure, runOnJS, + SharedValue, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useSharedValue, - withDecay, withSpring, } from 'react-native-reanimated' import {Image} from 'expo-image' -import {useImageDimensions} from '#/lib/media/image-sizes' -import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' +import type { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' import { applyRounding, createTransform, @@ -36,29 +41,46 @@ type Props = { onRequestClose: () => void onTap: () => void onZoom: (isZoomed: boolean) => void + onLoad: (dims: ImageDimensions) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } + imageAspect: number | undefined + imageDimensions: ImageDimensions | undefined + dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = ({ imageSrc, onTap, onZoom, - onRequestClose, + onLoad, isScrollViewBeingDragged, - safeAreaRef, + measureSafeArea, + imageAspect, + imageDimensions, + dismissSwipePan, + transforms, }: Props) => { const [isScaled, setIsScaled] = useState(false) - const [imageAspect, imageDimensions] = useImageDimensions({ - src: imageSrc.uri, - knownDimensions: imageSrc.dimensions, - }) const committedTransform = useSharedValue(initialTransform) const panTranslation = useSharedValue({x: 0, y: 0}) const pinchOrigin = useSharedValue({x: 0, y: 0}) const pinchScale = useSharedValue(1) const pinchTranslation = useSharedValue({x: 0, y: 0}) - const dismissSwipeTranslateY = useSharedValue(0) const containerRef = useAnimatedRef() // Keep track of when we're entering or leaving scaled rendering. @@ -89,30 +111,6 @@ const ImageItem = ({ onZoom(nextIsScaled) } - const animatedStyle = useAnimatedStyle(() => { - // Apply the active adjustments on top of the committed transform before the gestures. - // This is matrix multiplication, so operations are applied in the reverse order. - let t = createTransform() - prependPan(t, panTranslation.value) - prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) - prependTransform(t, committedTransform.value) - const [translateX, translateY, scale] = readTransform(t) - - const dismissDistance = dismissSwipeTranslateY.value - const screenSize = measure(safeAreaRef) - const dismissProgress = screenSize - ? Math.min(Math.abs(dismissDistance) / (screenSize.height / 2), 1) - : 0 - return { - opacity: 1 - dismissProgress, - transform: [ - {translateX}, - {translateY: translateY + dismissDistance}, - {scale}, - ], - } - }) - // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. function getExtraTranslationToStayInBounds( @@ -148,10 +146,7 @@ const ImageItem = ({ const pinch = Gesture.Pinch() .onStart(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!screenSize) { - return - } + const screenSize = measureSafeArea() pinchOrigin.value = { x: e.focalX - screenSize.width / 2, y: e.focalY - screenSize.height / 2, @@ -159,8 +154,8 @@ const ImageItem = ({ }) .onChange(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions) { return } // Don't let the picture zoom in so close that it gets blurry. @@ -218,8 +213,8 @@ const ImageItem = ({ .minPointers(isScaled ? 1 : 2) .onChange(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions) { return } @@ -262,8 +257,8 @@ const ImageItem = ({ .numberOfTaps(2) .onEnd(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !imageAspect || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions || !imageAspect) { return } const [, , committedScale] = readTransform(committedTransform.value) @@ -307,28 +302,6 @@ const ImageItem = ({ committedTransform.value = withClampedSpring(finalTransform) }) - const dismissSwipePan = Gesture.Pan() - .enabled(!isScaled) - .activeOffsetY([-10, 10]) - .failOffsetX([-10, 10]) - .maxPointers(1) - .onUpdate(e => { - 'worklet' - dismissSwipeTranslateY.value = e.translationY - }) - .onEnd(e => { - 'worklet' - if (Math.abs(e.velocityY) > 1000) { - dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY}) - runOnJS(onRequestClose)() - } else { - dismissSwipeTranslateY.value = withSpring(0, { - stiffness: 700, - damping: 50, - }) - } - }) - const composedGesture = isScrollViewBeingDragged ? // If the parent is not at rest, provide a no-op gesture. Gesture.Manual() @@ -339,27 +312,108 @@ const ImageItem = ({ singleTap, ) + const containerStyle = useAnimatedStyle(() => { + const {scaleAndMoveTransform, isHidden} = transforms.value + // Apply the active adjustments on top of the committed transform before the gestures. + // This is matrix multiplication, so operations are applied in the reverse order. + let t = createTransform() + prependPan(t, panTranslation.value) + prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) + prependTransform(t, committedTransform.value) + const [translateX, translateY, scale] = readTransform(t) + const manipulationTransform = [ + {translateX}, + {translateY: translateY}, + {scale}, + ] + const screenSize = measureSafeArea() + return { + opacity: isHidden ? 0 : 1, + transform: scaleAndMoveTransform.concat(manipulationTransform), + width: screenSize.width, + maxHeight: screenSize.height, + alignSelf: 'center', + aspectRatio: imageAspect ?? 1 /* force onLoad */, + } + }) + + const imageCropStyle = useAnimatedStyle(() => { + const {cropFrameTransform} = transforms.value + return { + flex: 1, + overflow: 'hidden', + transform: cropFrameTransform, + } + }) + + const imageStyle = useAnimatedStyle(() => { + const {cropContentTransform} = transforms.value + return { + flex: 1, + transform: cropContentTransform, + opacity: imageAspect === undefined ? 0 : 1, + } + }) + + const [showLoader, setShowLoader] = useState(false) + const [hasLoaded, setHasLoaded] = useState(false) + useAnimatedReaction( + () => { + return transforms.value.isResting && !hasLoaded + }, + (show, prevShow) => { + if (show && !prevShow) { + runOnJS(setShowLoader)(false) + } else if (!prevShow && show) { + runOnJS(setShowLoader)(true) + } + }, + ) + + const type = imageSrc.type + const borderRadius = + type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + return ( - - - - - - + + + + {showLoader && ( + + )} + + + { + setHasLoaded(true) + onLoad({width: e.source.width, height: e.source.height}) + } + } + style={{flex: 1, borderRadius}} + accessibilityHint="" + accessibilityIgnoresInvertColors + cachePolicy="memory" + /> + + + + + ) } @@ -367,9 +421,7 @@ const styles = StyleSheet.create({ container: { height: '100%', overflow: 'hidden', - }, - image: { - flex: 1, + justifyContent: 'center', }, loading: { position: 'absolute', @@ -377,6 +429,7 @@ const styles = StyleSheet.create({ right: 0, top: 0, bottom: 0, + justifyContent: 'center', }, }) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index a17d4fe66c..f06a59ed60 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -7,26 +7,29 @@ */ import React, {useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import {ActivityIndicator, StyleSheet} from 'react-native' +import { + Gesture, + GestureDetector, + PanGesture, +} from 'react-native-gesture-handler' import Animated, { - AnimatedRef, - interpolate, - measure, runOnJS, + SharedValue, + useAnimatedReaction, useAnimatedRef, useAnimatedStyle, - useSharedValue, } from 'react-native-reanimated' import {useSafeAreaFrame} from 'react-native-safe-area-context' import {Image} from 'expo-image' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' -import {useImageDimensions} from '#/lib/media/image-sizes' -import {ImageSource} from '../../@types' +import { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' -const SWIPE_CLOSE_OFFSET = 75 -const SWIPE_CLOSE_VELOCITY = 1 const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MIN_SCREEN_ZOOM = 2 @@ -35,27 +38,44 @@ type Props = { onRequestClose: () => void onTap: () => void onZoom: (scaled: boolean) => void + onLoad: (dims: ImageDimensions) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } + imageAspect: number | undefined + imageDimensions: ImageDimensions | undefined + dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = ({ imageSrc, onTap, onZoom, - onRequestClose, + onLoad, showControls, - safeAreaRef, + measureSafeArea, + imageAspect, + imageDimensions, + dismissSwipePan, + transforms, }: Props) => { const scrollViewRef = useAnimatedRef() - const translationY = useSharedValue(0) const [scaled, setScaled] = useState(false) const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame() - const [imageAspect, imageDimensions] = useImageDimensions({ - src: imageSrc.uri, - knownDimensions: imageSrc.dimensions, - }) const maxZoomScale = Math.max( MIN_SCREEN_ZOOM, imageDimensions @@ -64,35 +84,13 @@ const ImageItem = ({ : 1, ) - const animatedStyle = useAnimatedStyle(() => { - return { - flex: 1, - opacity: interpolate( - translationY.value, - [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - [0.5, 1, 0.5], - ), - } - }) - const scrollHandler = useAnimatedScrollHandler({ onScroll(e) { const nextIsScaled = e.zoomScale > 1 - translationY.value = nextIsScaled ? 0 : e.contentOffset.y if (scaled !== nextIsScaled) { runOnJS(handleZoom)(nextIsScaled) } }, - onEndDrag(e) { - const velocityY = e.velocity?.y ?? 0 - const nextIsScaled = e.zoomScale > 1 - if (scaled !== nextIsScaled) { - runOnJS(handleZoom)(nextIsScaled) - } - if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { - runOnJS(onRequestClose)() - } - }, }) function handleZoom(nextIsScaled: boolean) { @@ -123,10 +121,7 @@ const ImageItem = ({ .numberOfTaps(2) .onEnd(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!screenSize) { - return - } + const screenSize = measureSafeArea() const {absoluteX, absoluteY} = e let nextZoomRect = { x: 0, @@ -146,7 +141,63 @@ const ImageItem = ({ runOnJS(zoomTo)(nextZoomRect) }) - const composedGesture = Gesture.Exclusive(doubleTap, singleTap) + const composedGesture = Gesture.Exclusive( + dismissSwipePan, + doubleTap, + singleTap, + ) + + const containerStyle = useAnimatedStyle(() => { + const {scaleAndMoveTransform, isHidden} = transforms.value + return { + flex: 1, + transform: scaleAndMoveTransform, + opacity: isHidden ? 0 : 1, + } + }) + + const imageCropStyle = useAnimatedStyle(() => { + const screenSize = measureSafeArea() + const {cropFrameTransform} = transforms.value + return { + overflow: 'hidden', + transform: cropFrameTransform, + width: screenSize.width, + maxHeight: screenSize.height, + alignSelf: 'center', + aspectRatio: imageAspect ?? 1 /* force onLoad */, + opacity: imageAspect === undefined ? 0 : 1, + } + }) + + const imageStyle = useAnimatedStyle(() => { + const {cropContentTransform} = transforms.value + return { + transform: cropContentTransform, + width: '100%', + aspectRatio: imageAspect ?? 1 /* force onLoad */, + opacity: imageAspect === undefined ? 0 : 1, + } + }) + + const [showLoader, setShowLoader] = useState(false) + const [hasLoaded, setHasLoaded] = useState(false) + useAnimatedReaction( + () => { + return transforms.value.isResting && !hasLoaded + }, + (show, prevShow) => { + if (show && !prevShow) { + runOnJS(setShowLoader)(false) + } else if (!prevShow && show) { + runOnJS(setShowLoader)(true) + } + }, + ) + + const type = imageSrc.type + const borderRadius = + type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 return ( @@ -158,20 +209,35 @@ const ImageItem = ({ showsVerticalScrollIndicator={false} maximumZoomScale={maxZoomScale} onScroll={scrollHandler} - contentContainerStyle={styles.scrollContainer}> - + style={containerStyle} + bounces={scaled} + bouncesZoom={true} + centerContent> + {showLoader && ( - + )} + + + { + setHasLoaded(true) + onLoad({width: e.source.width, height: e.source.height}) + } + } + /> + @@ -186,9 +252,6 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, }, - scrollContainer: { - flex: 1, - }, image: { flex: 1, }, diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 383bec9951..b41e163832 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -2,18 +2,42 @@ import React from 'react' import {View} from 'react-native' -import {AnimatedRef} from 'react-native-reanimated' +import {PanGesture} from 'react-native-gesture-handler' +import {SharedValue} from 'react-native-reanimated' -import {ImageSource} from '../../@types' +import {Dimensions} from '#/lib/media/types' +import { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' type Props = { imageSrc: ImageSource onRequestClose: () => void onTap: () => void onZoom: (scaled: boolean) => void + onLoad: (dims: Dimensions) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } + imageAspect: number | undefined + imageDimensions: ImageDimensions | undefined + dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = (_props: Props) => { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 791701bca8..ab8306b36a 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -9,35 +9,64 @@ // https://github.com/jobtoday/react-native-image-viewing import React, {useCallback, useState} from 'react' -import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' +import { + LayoutAnimation, + PixelRatio, + Platform, + StyleSheet, + View, +} from 'react-native' +import {Gesture} from 'react-native-gesture-handler' import PagerView from 'react-native-pager-view' import Animated, { AnimatedRef, + cancelAnimation, + interpolate, + measure, + runOnJS, + SharedValue, + useAnimatedReaction, useAnimatedRef, useAnimatedStyle, + useDerivedValue, + useSharedValue, + withDecay, withSpring, } from 'react-native-reanimated' -import {Edge, SafeAreaView} from 'react-native-safe-area-context' +import { + Edge, + SafeAreaView, + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Trans} from '@lingui/macro' +import {Dimensions} from '#/lib/media/types' import {colors, s} from '#/lib/styles' import {isIOS} from '#/platform/detection' import {Lightbox} from '#/state/lightbox' import {Button} from '#/view/com/util/forms/Button' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' -import {ImageSource} from './@types' +import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' +import {ImageSource, Transform} from './@types' import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageItem from './components/ImageItem/ImageItem' +type Rect = {x: number; y: number; width: number; height: number} + +const PIXEL_RATIO = PixelRatio.get() const EDGES = Platform.OS === 'android' ? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area +const SLOW_SPRING = {stiffness: 120} +const FAST_SPRING = {stiffness: 700} + export default function ImageViewRoot({ - lightbox, + lightbox: nextLightbox, onRequestClose, onPressSave, onPressShare, @@ -47,24 +76,72 @@ export default function ImageViewRoot({ onPressSave: (uri: string) => void onPressShare: (uri: string) => void }) { + 'use no memo' const ref = useAnimatedRef() + const [activeLightbox, setActiveLightbox] = useState(nextLightbox) + const openProgress = useSharedValue(0) + + if (!activeLightbox && nextLightbox) { + setActiveLightbox(nextLightbox) + } + + React.useEffect(() => { + if (!nextLightbox) { + return + } + + const canAnimate = + !PlatformInfo.getIsReducedMotionEnabled() && + nextLightbox.images.every( + img => img.thumbRect && (img.dimensions || img.thumbDimensions), + ) + + // https://github.com/software-mansion/react-native-reanimated/issues/6677 + requestAnimationFrame(() => { + openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1 + }) + return () => { + // https://github.com/software-mansion/react-native-reanimated/issues/6677 + requestAnimationFrame(() => { + openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0 + }) + } + }, [nextLightbox, openProgress]) + + useAnimatedReaction( + () => openProgress.value === 0, + (isGone, wasGone) => { + if (isGone && !wasGone) { + runOnJS(setActiveLightbox)(null) + } + }, + ) + + const onFlyAway = React.useCallback(() => { + 'worklet' + openProgress.value = 0 + runOnJS(onRequestClose)() + }, [onRequestClose, openProgress]) + return ( // Keep it always mounted to avoid flicker on the first frame. + aria-hidden={!activeLightbox}> - {lightbox && ( + {activeLightbox && ( )} @@ -77,39 +154,83 @@ function ImageView({ onRequestClose, onPressSave, onPressShare, + onFlyAway, safeAreaRef, + openProgress, }: { lightbox: Lightbox onRequestClose: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void + onFlyAway: () => void safeAreaRef: AnimatedRef + openProgress: SharedValue }) { const {images, index: initialImageIndex} = lightbox const [isScaled, setIsScaled] = useState(false) const [isDragging, setIsDragging] = useState(false) const [imageIndex, setImageIndex] = useState(initialImageIndex) const [showControls, setShowControls] = useState(true) + const [isAltExpanded, setAltExpanded] = React.useState(false) + const dismissSwipeTranslateY = useSharedValue(0) + const isFlyingAway = useSharedValue(false) - const animatedHeaderStyle = useAnimatedStyle(() => ({ - pointerEvents: showControls ? 'box-none' : 'none', - opacity: withClampedSpring(showControls ? 1 : 0), - transform: [ - { - translateY: withClampedSpring(showControls ? 0 : -30), - }, - ], - })) - const animatedFooterStyle = useAnimatedStyle(() => ({ - flexGrow: 1, - pointerEvents: showControls ? 'box-none' : 'none', - opacity: withClampedSpring(showControls ? 1 : 0), - transform: [ - { - translateY: withClampedSpring(showControls ? 0 : 30), - }, - ], - })) + const containerStyle = useAnimatedStyle(() => { + if (openProgress.value < 1 || isFlyingAway.value) { + return {pointerEvents: 'none'} + } + return {pointerEvents: 'auto'} + }) + + const backdropStyle = useAnimatedStyle(() => { + const screenSize = measure(safeAreaRef) + let opacity = 1 + if (openProgress.value < 1) { + opacity = Math.sqrt(openProgress.value) + } else if (screenSize) { + const dragProgress = Math.min( + Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), + 1, + ) + opacity -= dragProgress + } + const factor = isIOS ? 100 : 50 + return { + opacity: Math.round(opacity * factor) / factor, + } + }) + + const animatedHeaderStyle = useAnimatedStyle(() => { + const show = showControls && dismissSwipeTranslateY.value === 0 + return { + pointerEvents: show ? 'box-none' : 'none', + opacity: withClampedSpring( + show && openProgress.value === 1 ? 1 : 0, + FAST_SPRING, + ), + transform: [ + { + translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING), + }, + ], + } + }) + const animatedFooterStyle = useAnimatedStyle(() => { + const show = showControls && dismissSwipeTranslateY.value === 0 + return { + flexGrow: 1, + pointerEvents: show ? 'box-none' : 'none', + opacity: withClampedSpring( + show && openProgress.value === 1 ? 1 : 0, + FAST_SPRING, + ), + transform: [ + { + translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING), + }, + ], + } + }) const onTap = useCallback(() => { setShowControls(show => !show) @@ -122,8 +243,29 @@ function ImageView({ } }, []) + useAnimatedReaction( + () => { + const screenSize = measure(safeAreaRef) + return ( + !screenSize || + Math.abs(dismissSwipeTranslateY.value) > screenSize.height + ) + }, + (isOut, wasOut) => { + if (isOut && !wasOut) { + // Stop the animation from blocking the screen forever. + cancelAnimation(dismissSwipeTranslateY) + onFlyAway() + } + }, + ) + return ( - + + - {images.map(imageSrc => ( + {images.map((imageSrc, i) => ( - ))} - + - + setAltExpanded(e => !e)} onPressSave={onPressSave} onPressShare={onPressShare} /> - + + ) +} + +function LightboxImage({ + imageSrc, + onTap, + onZoom, + onRequestClose, + isScrollViewBeingDragged, + isScaled, + isFlyingAway, + isActive, + showControls, + safeAreaRef, + openProgress, + dismissSwipeTranslateY, +}: { + imageSrc: ImageSource + onRequestClose: () => void + onTap: () => void + onZoom: (scaled: boolean) => void + isScrollViewBeingDragged: boolean + isScaled: boolean + isActive: boolean + isFlyingAway: SharedValue + showControls: boolean + safeAreaRef: AnimatedRef + openProgress: SharedValue + dismissSwipeTranslateY: SharedValue +}) { + const [fetchedDims, setFetchedDims] = React.useState(null) + const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions + let imageAspect: number | undefined + if (dims) { + imageAspect = dims.width / dims.height + if (Number.isNaN(imageAspect)) { + imageAspect = undefined + } + } + + const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame() + const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets() + const measureSafeArea = React.useCallback(() => { + 'worklet' + let safeArea: Rect | null = measure(safeAreaRef) + if (!safeArea) { + if (_WORKLET) { + console.error('Expected to always be able to measure safe area.') + } + const frame = safeFrameDelayedForJSThreadOnly + const insets = safeInsetsDelayedForJSThreadOnly + safeArea = { + x: frame.x + insets.left, + y: frame.y + insets.top, + width: frame.width - insets.left - insets.right, + height: frame.height - insets.top - insets.bottom, + } + } + return safeArea + }, [ + safeFrameDelayedForJSThreadOnly, + safeInsetsDelayedForJSThreadOnly, + safeAreaRef, + ]) + + const {thumbRect} = imageSrc + const transforms = useDerivedValue(() => { + 'worklet' + const safeArea = measureSafeArea() + const dismissTranslateY = + isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0 + + if (openProgress.value === 0 && isFlyingAway.value) { + return { + isHidden: true, + isResting: false, + scaleAndMoveTransform: [], + cropFrameTransform: [], + cropContentTransform: [], + } + } + + if (isActive && thumbRect && imageAspect && openProgress.value < 1) { + return interpolateTransform( + openProgress.value, + thumbRect, + safeArea, + imageAspect, + ) + } + return { + isHidden: false, + isResting: dismissTranslateY === 0, + scaleAndMoveTransform: [{translateY: dismissTranslateY}], + cropFrameTransform: [], + cropContentTransform: [], + } + }) + + const dismissSwipePan = Gesture.Pan() + .enabled(isActive && !isScaled) + .activeOffsetY([-10, 10]) + .failOffsetX([-10, 10]) + .maxPointers(1) + .onUpdate(e => { + 'worklet' + if (openProgress.value !== 1 || isFlyingAway.value) { + return + } + dismissSwipeTranslateY.value = e.translationY + }) + .onEnd(e => { + 'worklet' + if (openProgress.value !== 1 || isFlyingAway.value) { + return + } + if (Math.abs(e.velocityY) > 1000) { + isFlyingAway.value = true + if (dismissSwipeTranslateY.value === 0) { + // HACK: If the initial value is 0, withDecay() animation doesn't start. + // This is a bug in Reanimated, but for now we'll work around it like this. + dismissSwipeTranslateY.value = 1 + } + dismissSwipeTranslateY.value = withDecay({ + velocity: e.velocityY, + velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow. + deceleration: 1, // Danger! This relies on the reaction below stopping it. + }) + } else { + dismissSwipeTranslateY.value = withSpring(0, { + stiffness: 700, + damping: 50, + }) + } + }) + + return ( + ) } function LightboxFooter({ images, index, + isAltExpanded, + toggleAltExpanded, onPressSave, onPressShare, }: { images: ImageSource[] index: number + isAltExpanded: boolean + toggleAltExpanded: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void }) { const {alt: altText, uri} = images[index] - const [isAltExpanded, setAltExpanded] = React.useState(false) const isMomentumScrolling = React.useRef(false) return ( !prev) + toggleAltExpanded() }} onLongPress={() => {}}> {altText} @@ -256,7 +563,14 @@ const styles = StyleSheet.create({ }, container: { flex: 1, + }, + backdrop: { backgroundColor: '#000', + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, }, controls: { position: 'absolute', @@ -308,7 +622,91 @@ const styles = StyleSheet.create({ }, }) -function withClampedSpring(value: any) { +function interpolatePx( + px: number, + inputRange: readonly number[], + outputRange: readonly number[], +) { 'worklet' - return withSpring(value, {overshootClamping: true, stiffness: 300}) + const value = interpolate(px, inputRange, outputRange) + return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO +} + +function interpolateTransform( + progress: number, + thumbnailDims: { + pageX: number + width: number + pageY: number + height: number + }, + safeArea: {width: number; height: number; x: number; y: number}, + imageAspect: number, +): { + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean +} { + 'worklet' + const thumbAspect = thumbnailDims.width / thumbnailDims.height + let uncroppedInitialWidth + let uncroppedInitialHeight + if (imageAspect > thumbAspect) { + uncroppedInitialWidth = thumbnailDims.height * imageAspect + uncroppedInitialHeight = thumbnailDims.height + } else { + uncroppedInitialWidth = thumbnailDims.width + uncroppedInitialHeight = thumbnailDims.width / imageAspect + } + const safeAreaAspect = safeArea.width / safeArea.height + let finalWidth + let finalHeight + if (safeAreaAspect > imageAspect) { + finalWidth = safeArea.height * imageAspect + finalHeight = safeArea.height + } else { + finalWidth = safeArea.width + finalHeight = safeArea.width / imageAspect + } + const initialScale = Math.min( + uncroppedInitialWidth / finalWidth, + uncroppedInitialHeight / finalHeight, + ) + const croppedFinalWidth = thumbnailDims.width / initialScale + const croppedFinalHeight = thumbnailDims.height / initialScale + const screenCenterX = safeArea.width / 2 + const screenCenterY = safeArea.height / 2 + const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x + const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y + const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2 + const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2 + const initialTranslateX = thumbnailCenterX - screenCenterX + const initialTranslateY = thumbnailCenterY - screenCenterY + const scale = interpolate(progress, [0, 1], [initialScale, 1]) + const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0]) + const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0]) + const cropScaleX = interpolate( + progress, + [0, 1], + [croppedFinalWidth / finalWidth, 1], + ) + const cropScaleY = interpolate( + progress, + [0, 1], + [croppedFinalHeight / finalHeight, 1], + ) + return { + isHidden: false, + isResting: progress === 1, + scaleAndMoveTransform: [{translateX}, {translateY}, {scale}], + cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}], + cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}], + } +} + +function withClampedSpring(value: any, {stiffness}: {stiffness: number}) { + 'worklet' + return withSpring(value, {overshootClamping: true, stiffness}) } diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 2ba7d06cc5..f9b147b297 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -21,13 +21,9 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {colors, s} from '#/lib/styles' import {useLightbox, useLightboxControls} from '#/state/lightbox' import {Text} from '../util/text/Text' +import {ImageSource} from './ImageViewing/@types' import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' -interface Img { - uri: string - alt?: string -} - export function Lightbox() { const {activeLightbox} = useLightbox() const {closeLightbox} = useLightboxControls() @@ -54,7 +50,7 @@ function LightboxInner({ initialIndex = 0, onClose, }: { - imgs: Img[] + imgs: ImageSource[] initialIndex: number onClose: () => void }) { @@ -101,6 +97,8 @@ function LightboxInner({ return isTabletOrDesktop ? 32 : 24 }, [isTabletOrDesktop]) + const img = imgs[index] + const isAvi = img.type === 'circle-avi' || img.type === 'rect-avi' return ( - - - {canGoLeft && ( - - - - )} - {canGoRight && ( - - - - )} - + {isAvi ? ( + + {img.alt} + + ) : ( + + + {canGoLeft && ( + + + + )} + {canGoRight && ( + + + + )} + + )} - {imgs[index].alt ? ( + {img.alt ? ( - {imgs[index].alt} + {img.alt} @@ -203,6 +222,19 @@ const styles = StyleSheet.create({ height: '100%', resizeMode: 'contain', }, + aviCenterer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + avi: { + // @ts-ignore web-only + maxWidth: `calc(min(400px, 100vw))`, + // @ts-ignore web-only + maxHeight: `calc(min(400px, 100vh))`, + padding: 16, + boxSizing: 'border-box', + }, icon: { color: colors.white, }, diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx deleted file mode 100644 index f7991f59bf..0000000000 --- a/src/view/com/modals/AddAppPasswords.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import React, {useState} from 'react' -import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' -import {setStringAsync} from 'expo-clipboard' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {usePalette} from '#/lib/hooks/usePalette' -import {s} from '#/lib/styles' -import {logger} from '#/logger' -import {isNative} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import { - useAppPasswordCreateMutation, - useAppPasswordsQuery, -} from '#/state/queries/app-passwords' -import {Button} from '#/view/com/util/forms/Button' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a} from '#/alf' -import * as Toggle from '#/components/forms/Toggle' - -export const snapPoints = ['90%'] - -const shadesOfBlue: string[] = [ - 'AliceBlue', - 'Aqua', - 'Aquamarine', - 'Azure', - 'BabyBlue', - 'Blue', - 'BlueViolet', - 'CadetBlue', - 'CornflowerBlue', - 'Cyan', - 'DarkBlue', - 'DarkCyan', - 'DarkSlateBlue', - 'DeepSkyBlue', - 'DodgerBlue', - 'ElectricBlue', - 'LightBlue', - 'LightCyan', - 'LightSkyBlue', - 'LightSteelBlue', - 'MediumAquaMarine', - 'MediumBlue', - 'MediumSlateBlue', - 'MidnightBlue', - 'Navy', - 'PowderBlue', - 'RoyalBlue', - 'SkyBlue', - 'SlateBlue', - 'SteelBlue', - 'Teal', - 'Turquoise', -] - -export function Component({}: {}) { - const pal = usePalette('default') - const {_} = useLingui() - const {closeModal} = useModalControls() - const {data: passwords} = useAppPasswordsQuery() - const {mutateAsync: mutateAppPassword, isPending} = - useAppPasswordCreateMutation() - const [name, setName] = useState( - shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], - ) - const [appPassword, setAppPassword] = useState() - const [wasCopied, setWasCopied] = useState(false) - const [privileged, setPrivileged] = useState(false) - - const onCopy = React.useCallback(() => { - if (appPassword) { - setStringAsync(appPassword) - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') - setWasCopied(true) - } - }, [appPassword, _]) - - const onDone = React.useCallback(() => { - closeModal() - }, [closeModal]) - - const createAppPassword = async () => { - // if name is all whitespace, we don't allow it - if (!name || !name.trim()) { - Toast.show( - _( - msg`Please enter a name for your app password. All spaces is not allowed.`, - ), - 'xmark', - ) - return - } - // if name is too short (under 4 chars), we don't allow it - if (name.length < 4) { - Toast.show( - _(msg`App Password names must be at least 4 characters long.`), - 'xmark', - ) - return - } - - if (passwords?.find(p => p.name === name)) { - Toast.show(_(msg`This name is already in use`), 'xmark') - return - } - - try { - const newPassword = await mutateAppPassword({name, privileged}) - if (newPassword) { - setAppPassword(newPassword.password) - } else { - Toast.show(_(msg`Failed to create app password.`), 'xmark') - // TODO: better error handling (?) - } - } catch (e) { - Toast.show(_(msg`Failed to create app password.`), 'xmark') - logger.error('Failed to create app password', {message: e}) - } - } - - const _onChangeText = (text: string) => { - // sanitize input - // we only all alphanumeric characters, spaces, dashes, and underscores - // if the user enters anything else, we ignore it and shake the input container - // also, it cannot start with a space - if (text.match(/^[a-zA-Z0-9-_ ]*$/)) { - setName(text) - } else { - Toast.show( - _( - msg`App Password names can only contain letters, numbers, spaces, dashes, and underscores.`, - ), - 'xmark', - ) - } - } - - return ( - - {!appPassword ? ( - <> - - - - Please enter a unique name for this App Password or use our - randomly generated one. - - - - - - - - - Can only contain letters, numbers, spaces, dashes, and - underscores. Must be at least 4 characters long, but no more than - 32 characters long. - - - setPrivileged(val)} - name="privileged" - style={a.my_md}> - - - Allow access to your direct messages - - - - ) : ( - <> - - - - Here is your app password. - - - Use this to sign into the other app along with your handle. - - - - - {appPassword} - - {wasCopied ? ( - - Copied - - ) : ( - - )} - - - - - For security reasons, you won't be able to view this again. If you - lose this password, you'll need to generate a new one. - - - - )} - - - {canSave === true && ( - - - Domain verified! - - - )} - {error ? ( - - - {error} - - - ) : null} - - - - - Nevermind, create a handle for me - - - - ) -} - -const styles = StyleSheet.create({ - inner: { - padding: 14, - }, - footer: { - padding: 14, - }, - spacer: { - height: 20, - }, - dimmed: { - opacity: 0.7, - }, - - selectableBtns: { - flexDirection: 'row', - }, - - title: { - flexDirection: 'row', - alignItems: 'center', - paddingTop: 25, - paddingHorizontal: 20, - paddingBottom: 15, - borderBottomWidth: 1, - }, - titleLeft: { - width: 80, - }, - titleRight: { - width: 80, - flexDirection: 'row', - justifyContent: 'flex-end', - }, - titleMiddle: { - flex: 1, - textAlign: 'center', - fontSize: 21, - }, - - textInputWrapper: { - borderRadius: 8, - flexDirection: 'row', - alignItems: 'center', - }, - textInputIcon: { - marginLeft: 12, - }, - textInput: { - flex: 1, - width: '100%', - paddingVertical: 10, - paddingHorizontal: 8, - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - borderRadius: 10, - }, - - valueContainer: { - borderRadius: 4, - paddingVertical: 16, - }, - - dnsTable: { - borderRadius: 4, - paddingTop: 2, - paddingBottom: 16, - }, - dnsLabel: { - paddingHorizontal: 14, - paddingTop: 10, - }, - dnsValue: { - paddingHorizontal: 14, - borderRadius: 4, - }, - monoText: { - fontSize: 18, - lineHeight: 20, - }, - - message: { - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 8, - marginBottom: 10, - }, - - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 10, - marginBottom: 10, - }, - errorContainer: {marginBottom: 10}, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index becb39ff3d..78f4a01176 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -7,9 +7,7 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useModalControls, useModals} from '#/state/modals' import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as AddAppPassword from './AddAppPasswords' import * as ChangeEmailModal from './ChangeEmail' -import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' import * as DeleteAccountModal from './DeleteAccount' @@ -69,15 +67,9 @@ export function ModalsContainer() { } else if (activeModal?.name === 'delete-account') { snapPoints = DeleteAccountModal.snapPoints element = - } else if (activeModal?.name === 'change-handle') { - snapPoints = ChangeHandleModal.snapPoints - element = } else if (activeModal?.name === 'invite-codes') { snapPoints = InviteCodesModal.snapPoints element = - } else if (activeModal?.name === 'add-app-password') { - snapPoints = AddAppPassword.snapPoints - element = } else if (activeModal?.name === 'content-languages-settings') { snapPoints = ContentLanguagesSettingsModal.snapPoints element = diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 46ced58d9a..e9d9c01dd8 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -7,9 +7,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import type {Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' -import * as AddAppPassword from './AddAppPasswords' import * as ChangeEmailModal from './ChangeEmail' -import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' import * as CropImageModal from './CropImage.web' @@ -74,12 +72,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'delete-account') { element = - } else if (modal.name === 'change-handle') { - element = } else if (modal.name === 'invite-codes') { element = - } else if (modal.name === 'add-app-password') { - element = } else if (modal.name === 'content-languages-settings') { element = } else if (modal.name === 'post-languages-settings') { diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 5473fff854..b90f2ecd64 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -467,7 +467,12 @@ let FeedItem = ({ {item.type === 'feedgen-like' && item.subjectUri ? ( ) : null} @@ -778,7 +783,6 @@ const styles = StyleSheet.create({ opacity: 0.8, }, feedcard: { - borderWidth: 1, borderRadius: 8, paddingVertical: 12, marginTop: 6, diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index dc68ee7a17..09335fa0ed 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -7,6 +7,8 @@ import {Trans} from '@lingui/macro' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {FeedPostSlice} from '#/state/queries/post-feed' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {SubtleWebHover} from '#/components/SubtleWebHover' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {FeedItem} from './FeedItem' @@ -108,6 +110,11 @@ FeedSlice = memo(FeedSlice) export {FeedSlice} function ViewFullThread({uri}: {uri: string}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() const pal = usePalette('default') const itemHref = React.useMemo(() => { const urip = new AtUri(uri) @@ -115,7 +122,18 @@ function ViewFullThread({uri}: {uri: string}) { }, [uri]) return ( - + + { if (navigation.canGoBack()) { @@ -66,27 +74,40 @@ export function ProfileSubpageHeader({ setDrawerOpen(true) }, [setDrawerOpen]) - const onPressAvi = React.useCallback(() => { - if ( - avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) - ) { + const _openLightbox = React.useCallback( + (uri: string, thumbRect: MeasuredDimensions | null) => { openLightbox({ images: [ { - uri: avatar, - thumbUri: avatar, + uri, + thumbUri: uri, + thumbRect, dimensions: { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, width: 1000, }, + thumbDimensions: null, + type: 'rect-avi', }, ], index: 0, - thumbDims: null, }) + }, + [openLightbox], + ) + + const onPressAvi = React.useCallback(() => { + if ( + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { + runOnUI(() => { + 'worklet' + const rect = measure(aviRef) + runOnJS(_openLightbox)(avatar, rect) + })() } - }, [openLightbox, avatar]) + }, [_openLightbox, avatar, aviRef]) return ( @@ -134,19 +155,21 @@ export function ProfileSubpageHeader({ paddingBottom: 6, paddingHorizontal: isMobile ? 12 : 14, }}> - - {avatarType === 'starter-pack' ? ( - - ) : ( - - )} - + + + {avatarType === 'starter-pack' ? ( + + ) : ( + + )} + + {isLoading ? ( - - - - - - { - removeAccount(account) - Toast.show(_(msg`Account removed from quick access`)) - }} - confirmButtonCta={_(msg`Remove`)} - confirmButtonColor="negative" - /> - - ) -} diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 59a79b5313..d9a2e351e1 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -448,7 +448,9 @@ let Row = function RowImpl({ onItemSeen: ((item: any) => void) | undefined }): React.ReactNode { const rowRef = React.useRef(null) - const intersectionTimeout = React.useRef(undefined) + const intersectionTimeout = React.useRef< + ReturnType | undefined + >(undefined) const handleIntersection = useNonReactiveCallback( (entries: IntersectionObserverEntry[]) => { @@ -466,7 +468,7 @@ let Row = function RowImpl({ } } else { if (intersectionTimeout.current) { - clearTimeout(intersectionTimeout.current) + clearTimeout(intersectionTimeout.current as NodeJS.Timeout) intersectionTimeout.current = undefined } } diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 23dffc561d..193d07d724 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -3,6 +3,7 @@ import {NativeScrollEvent} from 'react-native' import { cancelAnimation, interpolate, + makeMutable, useSharedValue, withSpring, } from 'react-native-reanimated' @@ -20,6 +21,18 @@ function clamp(num: number, min: number, max: number) { return Math.min(Math.max(num, min), max) } +const V0 = makeMutable( + withSpring(0, { + overshootClamping: true, + }), +) + +const V1 = makeMutable( + withSpring(1, { + overshootClamping: true, + }), +) + export function MainScrollProvider({children}: {children: React.ReactNode}) { const {headerHeight} = useShellLayout() const {headerMode} = useMinimalShellMode() @@ -31,9 +44,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (v: boolean) => { 'worklet' cancelAnimation(headerMode) - headerMode.value = withSpring(v ? 1 : 0, { - overshootClamping: true, - }) + headerMode.value = v ? V1.value : V0.value }, [headerMode], ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c0166a16ee..5384f6827e 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -49,6 +49,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { precacheProfile(queryClient, opts.author) }, [queryClient, opts.author]) + const timestampLabel = niceDate(i18n, opts.timestamp) + return ( { {({timeElapsed}) => ( void + onPress?: ( + containerRef: AnimatedRef>, + fetchedDims: Dimensions | null, + ) => void onLongPress?: () => void onPressIn?: () => void }) { const t = useTheme() const {_} = useLingui() const largeAlt = useLargeAltBadgeEnabled() - const { - constrained, - max, - isCropped: rawIsCropped, - } = useImageAspectRatio({ - src: image.thumb, - knownDimensions: image.aspectRatio ?? null, - }) + const containerRef = useAnimatedRef() + + const [fetchedDims, setFetchedDims] = React.useState(null) + const dims = fetchedDims ?? image.aspectRatio + let aspectRatio: number | undefined + if (dims) { + aspectRatio = dims.width / dims.height + if (Number.isNaN(aspectRatio)) { + aspectRatio = undefined + } + } + + let constrained: number | undefined + let max: number | undefined + let rawIsCropped: boolean | undefined + if (aspectRatio !== undefined) { + const ratio = 1 / 2 // max of 1:2 ratio in feeds + constrained = Math.max(aspectRatio, ratio) + max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread + rawIsCropped = aspectRatio < constrained + } + const cropDisabled = crop === 'none' const isCropped = rawIsCropped && !cropDisabled const hasAlt = !!image.alt const contents = ( - <> + { + setFetchedDims({width: e.source.width, height: e.source.height}) + } + } /> @@ -185,13 +185,13 @@ export function AutoSizedImage({ )} ) : null} - + ) if (cropDisabled) { return ( onPress?.(containerRef, fetchedDims)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use @@ -213,7 +213,7 @@ export function AutoSizedImage({ fullBleed={crop === 'square'} aspectRatio={constrained ?? 1}> onPress?.(containerRef, fetchedDims)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index d4d7d223d5..9d0817bd2f 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,11 +1,12 @@ import React from 'react' import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' +import Animated, {AnimatedRef} from 'react-native-reanimated' import {Image, ImageStyle} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {atoms as a, useTheme} from '#/alf' @@ -19,13 +20,16 @@ interface Props { index: number onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], + fetchedDims: (Dimensions | null)[], ) => void onLongPress?: EventFunction onPressIn?: EventFunction imageStyle?: StyleProp viewContext?: PostEmbedViewContext insetBorderStyle?: StyleProp + containerRefs: AnimatedRef>[] + thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]> } export function GalleryItem({ @@ -37,6 +41,8 @@ export function GalleryItem({ onLongPress, viewContext, insetBorderStyle, + containerRefs, + thumbDimsRef, }: Props) { const t = useTheme() const {_} = useLingui() @@ -45,11 +51,17 @@ export function GalleryItem({ const hasAlt = !!image.alt const hideBadges = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - const containerRef = useAnimatedRef() return ( - + onPress(index, containerRef) : undefined} + onPress={ + onPress + ? () => onPress(index, containerRefs, thumbDimsRef.current.slice()) + : undefined + } onPressIn={onPressIn ? () => onPressIn(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined} style={[ @@ -68,6 +80,12 @@ export function GalleryItem({ accessibilityLabel={image.alt} accessibilityHint="" accessibilityIgnoresInvertColors + onLoad={e => { + thumbDimsRef.current[index] = { + width: e.source.width, + height: e.source.height, + } + }} /> diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 830040ba6c..dcc330dace 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,17 +1,19 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AnimatedRef} from 'react-native-reanimated' +import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {AppBskyEmbedImages} from '@atproto/api' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {atoms as a, useBreakpoints} from '#/alf' +import {Dimensions} from '../../lightbox/ImageViewing/@types' import {GalleryItem} from './Gallery' interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], + fetchedDims: (Dimensions | null)[], ) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void @@ -27,11 +29,10 @@ export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { ? a.gap_xs : a.gap_2xs : a.gap_xs - const count = props.images.length - const aspectRatio = count === 3 ? 2 : undefined + return ( - + @@ -42,7 +43,8 @@ interface ImageLayoutGridInnerProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], + fetchedDims: (Dimensions | null)[], ) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void @@ -54,8 +56,15 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { const gap = props.gap const count = props.images.length + const containerRef1 = useAnimatedRef() + const containerRef2 = useAnimatedRef() + const containerRef3 = useAnimatedRef() + const containerRef4 = useAnimatedRef() + const thumbDimsRef = React.useRef<(Dimensions | null)[]>([]) + switch (count) { - case 2: + case 2: { + const containerRefs = [containerRef1, containerRef2] return ( @@ -63,6 +72,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { {...props} index={0} insetBorderStyle={noCorners(['topRight', 'bottomRight'])} + containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -70,22 +81,28 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { {...props} index={1} insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} + containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> ) + } - case 3: + case 3: { + const containerRefs = [containerRef1, containerRef2, containerRef3] return ( - + - + @@ -106,13 +125,22 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'topRight', ])} + containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> ) + } - case 4: + case 4: { + const containerRefs = [ + containerRef1, + containerRef2, + containerRef3, + containerRef4, + ] return ( <> @@ -125,6 +153,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'topRight', 'bottomRight', ])} + containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -136,6 +166,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'bottomRight', ])} + containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -149,6 +181,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'topRight', 'bottomRight', ])} + containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -160,11 +194,14 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'topRight', ])} + containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> ) + } default: return null diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index d686d2bd32..1351a2cbc3 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -6,13 +6,12 @@ import { View, ViewStyle, } from 'react-native' -import Animated, { +import { AnimatedRef, measure, MeasuredDimensions, runOnJS, runOnUI, - useAnimatedRef, } from 'react-native-reanimated' import {Image} from 'expo-image' import { @@ -36,6 +35,7 @@ import {atoms as a, useTheme} from '#/alf' import * as ListCard from '#/components/ListCard' import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {ContentHider} from '../../../../components/moderation/ContentHider' +import {Dimensions} from '../../lightbox/ImageViewing/@types' import {AutoSizedImage} from '../images/AutoSizedImage' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ExternalLinkEmbed} from './ExternalLinkEmbed' @@ -69,7 +69,6 @@ export function PostEmbeds({ viewContext?: PostEmbedViewContext }) { const {openLightbox} = useLightboxControls() - const containerRef = useAnimatedRef() // quote post with media // = @@ -149,22 +148,28 @@ export function PostEmbeds({ })) const _openLightbox = ( index: number, - thumbDims: MeasuredDimensions | null, + thumbRects: (MeasuredDimensions | null)[], + fetchedDims: (Dimensions | null)[], ) => { openLightbox({ - images: items, + images: items.map((item, i) => ({ + ...item, + thumbRect: thumbRects[i] ?? null, + thumbDimensions: fetchedDims[i] ?? null, + type: 'image', + })), index, - thumbDims, }) } const onPress = ( index: number, - ref: AnimatedRef>, + refs: AnimatedRef>[], + fetchedDims: (Dimensions | null)[], ) => { runOnUI(() => { 'worklet' - const dims = measure(ref) - runOnJS(_openLightbox)(index, dims) + const rects = refs.map(ref => (ref ? measure(ref) : null)) + runOnJS(_openLightbox)(index, rects, fetchedDims) })() } const onPressIn = (_: number) => { @@ -177,7 +182,7 @@ export function PostEmbeds({ const image = images[0] return ( - + onPress(0, containerRef)} + onPress={(containerRef, dims) => + onPress(0, [containerRef], [dims]) + } onPressIn={() => onPressIn(0)} hideBadge={ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia } /> - + ) } diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index 42ea79b8fb..dbf5e2e13f 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -5,7 +5,7 @@ import {UITextView} from 'react-native-uitextview' import {lh, s} from '#/lib/styles' import {TypographyVariant, useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' -import {isIOS} from '#/platform/detection' +import {isIOS, isWeb} from '#/platform/detection' import {applyFonts, useAlf} from '#/alf' import { childHasEmoji, @@ -44,8 +44,6 @@ export function Text({ ...props }: React.PropsWithChildren) { const theme = useTheme() - const typography = theme.typography[type] - const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined const {fonts} = useAlf() if (IS_DEV) { @@ -60,7 +58,10 @@ export function Text({ } } - if (selectable && isIOS) { + const textProps = React.useMemo(() => { + const typography = theme.typography[type] + const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined + const flattened = StyleSheet.flatten([ s.black, typography, @@ -74,49 +75,47 @@ export function Text({ // @ts-ignore if (flattened.fontSize) { // @ts-ignore - flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier + flattened.fontSize = Math.round( + // @ts-ignore + flattened.fontSize * fonts.scaleMultiplier, + ) } - const shared = { - uiTextView: true, + return { + uiTextView: selectable && isIOS, selectable, style: flattened, + dataSet: isWeb + ? Object.assign({tooltip: title}, dataSet || {}) + : undefined, ...props, } + }, [ + dataSet, + fonts.family, + fonts.scaleMultiplier, + lineHeight, + props, + selectable, + style, + theme, + title, + type, + ]) + if (selectable && isIOS) { return ( - - {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} + + {isIOS && emoji + ? renderChildrenWithEmoji(children, textProps) + : children} ) } - const flattened = StyleSheet.flatten([ - s.black, - typography, - lineHeightStyle, - style, - ]) - - applyFonts(flattened, fonts.family) - - // should always be defined on `typography` - // @ts-ignore - if (flattened.fontSize) { - // @ts-ignore - flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier - } - - const shared = { - selectable, - style: flattened, - dataSet: Object.assign({tooltip: title}, dataSet || {}), - ...props, - } - return ( - - {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} + + {isIOS && emoji ? renderChildrenWithEmoji(children, textProps) : children} ) } diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx deleted file mode 100644 index 4dd5aa97be..0000000000 --- a/src/view/screens/AccessibilitySettings.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {s} from '#/lib/styles' -import {isNative} from '#/platform/detection' -import { - useAutoplayDisabled, - useHapticsDisabled, - useRequireAltTextEnabled, - useSetAutoplayDisabled, - useSetHapticsDisabled, - useSetRequireAltTextEnabled, -} from '#/state/preferences' -import { - useLargeAltBadgeEnabled, - useSetLargeAltBadgeEnabled, -} from '#/state/preferences/large-alt-badge' -import {useSetMinimalShellMode} from '#/state/shell' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from '#/view/com/util/Views' -import {AccessibilitySettingsScreen as NewAccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' -import {atoms as a} from '#/alf' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'AccessibilitySettings' -> -export function AccessibilitySettingsScreen(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyAccessibilitySettingsScreen({}: Props) { - const pal = usePalette('default') - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile, isTabletOrMobile} = useWebMediaQueries() - const {_} = useLingui() - - const requireAltTextEnabled = useRequireAltTextEnabled() - const setRequireAltTextEnabled = useSetRequireAltTextEnabled() - const autoplayDisabled = useAutoplayDisabled() - const setAutoplayDisabled = useSetAutoplayDisabled() - const hapticsDisabled = useHapticsDisabled() - const setHapticsDisabled = useSetHapticsDisabled() - const largeAltBadgeEnabled = useLargeAltBadgeEnabled() - const setLargeAltBadgeEnabled = useSetLargeAltBadgeEnabled() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - return ( - - - - - Accessibility Settings - - - - - - Alt text - - - setRequireAltTextEnabled(!requireAltTextEnabled)} - /> - setLargeAltBadgeEnabled(!largeAltBadgeEnabled)} - /> - - - Media - - - setAutoplayDisabled(!autoplayDisabled)} - /> - - {isNative && ( - <> - - Haptics - - - setHapticsDisabled(!hapticsDisabled)} - /> - - - )} - - - ) -} - -const styles = StyleSheet.create({ - heading: { - paddingHorizontal: 18, - paddingTop: 14, - paddingBottom: 6, - }, - toggleCard: { - paddingVertical: 8, - paddingHorizontal: 6, - marginBottom: 1, - }, -}) diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx deleted file mode 100644 index 09da3c1d2c..0000000000 --- a/src/view/screens/AppPasswords.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams} from '#/lib/routes/types' -import {cleanError} from '#/lib/strings/errors' -import {useModalControls} from '#/state/modals' -import { - useAppPasswordDeleteMutation, - useAppPasswordsQuery, -} from '#/state/queries/app-passwords' -import {useSetMinimalShellMode} from '#/state/shell' -import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' -import {Button} from '#/view/com/util/forms/Button' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {CenteredView} from '#/view/com/util/Views' -import {AppPasswordsScreen as NewAppPasswordsScreen} from '#/screens/Settings/AppPasswords' -import {atoms as a} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import * as Layout from '#/components/Layout' -import * as Prompt from '#/components/Prompt' - -type Props = NativeStackScreenProps -export function AppPasswords(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - - - ) -} - -function AppPasswordsInner() { - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop} = useWebMediaQueries() - const {openModal} = useModalControls() - const {data: appPasswords, error} = useAppPasswordsQuery() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const onAdd = React.useCallback(async () => { - openModal({name: 'add-app-password'}) - }, [openModal]) - - if (error) { - return ( - - - - ) - } - - // no app passwords (empty) state - if (appPasswords?.length === 0) { - return ( - - - - - - You have not created any app passwords yet. You can create one by - pressing the button below. - - - - {!isTabletOrDesktop && } - - - - - ) - } - - if (appPasswords?.length) { - // has app passwords - return ( - - - - {appPasswords.map((password, i) => ( - - ))} - {isTabletOrDesktop && ( - - - - )} - - {!isTabletOrDesktop && ( - - - - )} - - ) - } - - return ( - - - - ) -} - -function AppPasswordsHeader() { - const {isTabletOrDesktop} = useWebMediaQueries() - const pal = usePalette('default') - const {_} = useLingui() - return ( - <> - - - - Use app passwords to login to other Bluesky clients without giving - full access to your account or password. - - - - ) -} - -function AppPassword({ - testID, - name, - createdAt, - privileged, -}: { - testID: string - name: string - createdAt: string - privileged?: boolean -}) { - const pal = usePalette('default') - const {_, i18n} = useLingui() - const control = useDialogControl() - const deleteMutation = useAppPasswordDeleteMutation() - - const onDelete = React.useCallback(async () => { - await deleteMutation.mutateAsync({name}) - Toast.show(_(msg`App password deleted`)) - }, [deleteMutation, name, _]) - - const onPress = React.useCallback(() => { - control.open() - }, [control]) - - return ( - - - - {name} - - - - Created{' '} - {i18n.date(createdAt, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - })} - - - {privileged && ( - - - - Allows access to direct messages - - - )} - - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: 100, - }, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 0, - }, - title: { - textAlign: 'center', - marginTop: 12, - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 20, - marginBottom: 14, - }, - descriptionDesktop: { - marginTop: 14, - }, - - scrollContainer: { - borderTopWidth: 1, - marginTop: 4, - marginBottom: 16, - }, - - flex1: { - flex: 1, - }, - empty: { - paddingHorizontal: 20, - paddingVertical: 20, - borderRadius: 16, - marginHorizontal: 24, - marginTop: 10, - }, - emptyText: { - textAlign: 'center', - }, - - item: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderBottomWidth: 1, - paddingHorizontal: 20, - paddingVertical: 14, - }, - pr10: { - marginRight: 10, - }, - btnContainer: { - flexDirection: 'row', - justifyContent: 'center', - }, - btnContainerDesktop: { - marginTop: 14, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - paddingHorizontal: 60, - paddingVertical: 14, - }, - btnLabel: { - fontSize: 18, - }, - - trashIcon: { - color: 'red', - minWidth: 16, - }, -}) diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx deleted file mode 100644 index f99cccee9d..0000000000 --- a/src/view/screens/LanguageSettings.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' - -import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages' -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {s} from '#/lib/styles' -import {sanitizeAppLanguageSetting} from '#/locale/helpers' -import {useModalControls} from '#/state/modals' -import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' -import {useSetMinimalShellMode} from '#/state/shell' -import {Button} from '#/view/com/util/forms/Button' -import {Text} from '#/view/com/util/text/Text' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {CenteredView} from '#/view/com/util/Views' -import {LanguageSettingsScreen as NewLanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps - -export function LanguageSettingsScreen(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyLanguageSettingsScreen(_props: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const langPrefs = useLanguagePrefs() - const setLangPrefs = useLanguagePrefsApi() - const {isTabletOrDesktop} = useWebMediaQueries() - const setMinimalShellMode = useSetMinimalShellMode() - const {openModal} = useModalControls() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const onPressContentLanguages = React.useCallback(() => { - openModal({name: 'content-languages-settings'}) - }, [openModal]) - - const onChangePrimaryLanguage = React.useCallback( - (value: Parameters[0]) => { - if (!value) return - if (langPrefs.primaryLanguage !== value) { - setLangPrefs.setPrimaryLanguage(value) - } - }, - [langPrefs, setLangPrefs], - ) - - const onChangeAppLanguage = React.useCallback( - (value: Parameters[0]) => { - if (!value) return - if (langPrefs.appLanguage !== value) { - setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value)) - } - }, - [langPrefs, setLangPrefs], - ) - - const myLanguages = React.useMemo(() => { - return ( - langPrefs.contentLanguages - .map(lang => LANGUAGES.find(l => l.code2 === lang)) - .filter(Boolean) - // @ts-ignore - .map(l => l.name) - .join(', ') - ) - }, [langPrefs.contentLanguages]) - - return ( - - - - - - {/* APP LANGUAGE */} - - - App Language - - - - Select your app language for the default text to display in the - app. - - - - - Boolean(l.code2)).map(l => ({ - label: l.name, - value: l.code2, - key: l.code2, - }))} - style={{ - inputAndroid: { - backgroundColor: pal.viewLight.backgroundColor, - color: pal.text.color, - fontSize: 14, - letterSpacing: 0.5, - fontWeight: '600', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 24, - }, - inputIOS: { - backgroundColor: pal.viewLight.backgroundColor, - color: pal.text.color, - fontSize: 14, - letterSpacing: 0.5, - fontWeight: '600', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 24, - }, - - inputWeb: { - cursor: 'pointer', - // @ts-ignore web only - '-moz-appearance': 'none', - '-webkit-appearance': 'none', - appearance: 'none', - outline: 0, - borderWidth: 0, - backgroundColor: pal.viewLight.backgroundColor, - color: pal.text.color, - fontSize: 14, - fontFamily: 'inherit', - letterSpacing: 0.5, - fontWeight: '600', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 24, - }, - }} - /> - - - - - - - - - - {/* PRIMARY LANGUAGE */} - - - Primary Language - - - - Select your preferred language for translations in your feed. - - - - - Boolean(l.code2)).map(l => ({ - label: l.name, - value: l.code2, - key: l.code2 + l.code3, - }))} - style={{ - inputAndroid: { - backgroundColor: pal.viewLight.backgroundColor, - color: pal.text.color, - fontSize: 14, - letterSpacing: 0.5, - fontWeight: '600', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 24, - }, - inputIOS: { - backgroundColor: pal.viewLight.backgroundColor, - color: pal.text.color, - fontSize: 14, - letterSpacing: 0.5, - fontWeight: '600', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 24, - }, - inputWeb: { - cursor: 'pointer', - // @ts-ignore web only - '-moz-appearance': 'none', - '-webkit-appearance': 'none', - appearance: 'none', - outline: 0, - borderWidth: 0, - backgroundColor: pal.viewLight.backgroundColor, - color: pal.text.color, - fontSize: 14, - fontFamily: 'inherit', - letterSpacing: 0.5, - fontWeight: '600', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 24, - }, - }} - /> - - - - - - - - - - {/* CONTENT LANGUAGES */} - - - Content Languages - - - - Select which languages you want your subscribed feeds to - include. If none are selected, all languages will be shown. - - - - - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: 90, - }, - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 40, - }, - button: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, -}) diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index b79da6d540..f654f2bd93 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -2,9 +2,11 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {AtUri} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useEmail} from '#/lib/hooks/useEmail' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' @@ -16,15 +18,20 @@ import {MyLists} from '#/view/com/lists/MyLists' import {Button} from '#/view/com/util/forms/Button' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export function ListsScreen({}: Props) { + const {_} = useLingui() const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation() const {openModal} = useModalControls() + const {needsEmailVerification} = useEmail() + const control = useDialogControl() useFocusEffect( React.useCallback(() => { @@ -33,6 +40,11 @@ export function ListsScreen({}: Props) { ) const onPressNewList = React.useCallback(() => { + if (needsEmailVerification) { + control.open() + return + } + openModal({ name: 'create-or-edit-list', purpose: 'app.bsky.graph.defs#curatelist', @@ -46,7 +58,7 @@ export function ListsScreen({}: Props) { } catch {} }, }) - }, [openModal, navigation]) + }, [needsEmailVerification, control, openModal, navigation]) return ( @@ -87,6 +99,12 @@ export function ListsScreen({}: Props) { + ) } diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index b147ba502d..c623c5376f 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -2,9 +2,11 @@ import React from 'react' import {View} from 'react-native' import {AtUri} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useEmail} from '#/lib/hooks/useEmail' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' @@ -16,15 +18,20 @@ import {MyLists} from '#/view/com/lists/MyLists' import {Button} from '#/view/com/util/forms/Button' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export function ModerationModlistsScreen({}: Props) { + const {_} = useLingui() const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation() const {openModal} = useModalControls() + const {needsEmailVerification} = useEmail() + const control = useDialogControl() useFocusEffect( React.useCallback(() => { @@ -33,6 +40,11 @@ export function ModerationModlistsScreen({}: Props) { ) const onPressNewList = React.useCallback(() => { + if (needsEmailVerification) { + control.open() + return + } + openModal({ name: 'create-or-edit-list', purpose: 'app.bsky.graph.defs#modlist', @@ -46,7 +58,7 @@ export function ModerationModlistsScreen({}: Props) { } catch {} }, }) - }, [openModal, navigation]) + }, [needsEmailVerification, control, openModal, navigation]) return ( @@ -83,6 +95,12 @@ export function ModerationModlistsScreen({}: Props) { + ) } diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx deleted file mode 100644 index ef3f73b3cd..0000000000 --- a/src/view/screens/PreferencesExternalEmbeds.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {Trans} from '@lingui/macro' -import {useFocusEffect} from '@react-navigation/native' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import { - EmbedPlayerSource, - externalEmbedLabels, -} from '#/lib/strings/embed-player' -import { - useExternalEmbedsPrefs, - useSetExternalEmbedPref, -} from '#/state/preferences' -import {useSetMinimalShellMode} from '#/state/shell' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from '#/view/com/util/Views' -import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' -import {atoms as a} from '#/alf' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'PreferencesExternalEmbeds' -> -export function PreferencesExternalEmbeds(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyPreferencesExternalEmbeds({}: Props) { - const pal = usePalette('default') - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrMobile} = useWebMediaQueries() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - return ( - - - - - - External Media Preferences - - - Customize media from external sites. - - - - - - - - - External media may allow websites to collect information about - you and your device. No information is sent or requested until - you press the "play" button. - - - - - - Enable media players for - - {Object.entries(externalEmbedLabels) - // TODO: Remove special case when we disable the old integration. - .filter(([key]) => key !== 'tenor') - .map(([key, label]) => ( - - ))} - - - ) -} - -function PrefSelector({ - source, - label, -}: { - source: EmbedPlayerSource - label: string -}) { - const pal = usePalette('default') - const setExternalEmbedPref = useSetExternalEmbedPref() - const sources = useExternalEmbedsPrefs() - - return ( - - - - setExternalEmbedPref( - source, - sources?.[source] === 'show' ? 'hide' : 'show', - ) - } - /> - - - ) -} - -const styles = StyleSheet.create({ - heading: { - paddingHorizontal: 18, - paddingTop: 14, - paddingBottom: 14, - }, - spacer: { - height: 8, - }, - infoCard: { - paddingHorizontal: 20, - paddingVertical: 14, - }, - toggleCard: { - paddingVertical: 8, - paddingHorizontal: 6, - marginBottom: 1, - }, -}) diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx deleted file mode 100644 index c31a23c49a..0000000000 --- a/src/view/screens/PreferencesFollowingFeed.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {colors, s} from '#/lib/styles' -import { - usePreferencesQuery, - useSetFeedViewPreferencesMutation, -} from '#/state/queries/preferences' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from '#/view/com/util/Views' -import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' -import {atoms as a} from '#/alf' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'PreferencesFollowingFeed' -> -export function PreferencesFollowingFeed(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyPreferencesFollowingFeed({}: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const {isTabletOrMobile} = useWebMediaQueries() - const {data: preferences} = usePreferencesQuery() - const {mutate: setFeedViewPref, variables} = - useSetFeedViewPreferencesMutation() - - const showReplies = !( - variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies - ) - - return ( - - - - - - Following Feed Preferences - - - - Fine-tune the content you see on your Following feed. - - - - - - - - Show Replies - - - - Set this setting to "No" to hide all replies from your feed. - - - - setFeedViewPref({ - hideReplies: !( - variables?.hideReplies ?? - preferences?.feedViewPrefs?.hideReplies - ), - }) - } - /> - - - - Show Reposts - - - - Set this setting to "No" to hide all reposts from your feed. - - - - setFeedViewPref({ - hideReposts: !( - variables?.hideReposts ?? - preferences?.feedViewPrefs?.hideReposts - ), - }) - } - /> - - - - - Show Quote Posts - - - - Set this setting to "No" to hide all quote posts from your feed. - Reposts will still be visible. - - - - setFeedViewPref({ - hideQuotePosts: !( - variables?.hideQuotePosts ?? - preferences?.feedViewPrefs?.hideQuotePosts - ), - }) - } - /> - - - - - {' '} - Show Posts from My Feeds - - - - Set this setting to "Yes" to show samples of your saved feeds in - your Following feed. This is an experimental feature. - - - - setFeedViewPref({ - lab_mergeFeedEnabled: !( - variables?.lab_mergeFeedEnabled ?? - preferences?.feedViewPrefs?.lab_mergeFeedEnabled - ), - }) - } - /> - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - titleSection: { - paddingBottom: 30, - }, - title: { - textAlign: 'center', - marginBottom: 5, - }, - description: { - textAlign: 'center', - paddingHorizontal: 32, - }, - cardsContainer: { - paddingHorizontal: 20, - paddingVertical: 16, - }, - card: { - padding: 16, - borderRadius: 10, - marginBottom: 20, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - backgroundColor: colors.blue3, - }, - btnDesktop: { - marginHorizontal: 'auto', - paddingHorizontal: 80, - }, - btnContainer: { - paddingTop: 20, - }, - dimmed: { - opacity: 0.3, - }, -}) diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx deleted file mode 100644 index f511f4c59e..0000000000 --- a/src/view/screens/PreferencesThreads.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {colors, s} from '#/lib/styles' -import { - usePreferencesQuery, - useSetThreadViewPreferencesMutation, -} from '#/state/queries/preferences' -import {RadioGroup} from '#/view/com/util/forms/RadioGroup' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from '#/view/com/util/Views' -import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' -import {atoms as a} from '#/alf' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps -export function PreferencesThreads(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyPreferencesThreads({}: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const {isTabletOrMobile} = useWebMediaQueries() - const {data: preferences} = usePreferencesQuery() - const {mutate: setThreadViewPrefs, variables} = - useSetThreadViewPreferencesMutation() - - const prioritizeFollowedUsers = Boolean( - variables?.prioritizeFollowedUsers ?? - preferences?.threadViewPrefs?.prioritizeFollowedUsers, - ) - const treeViewEnabled = Boolean( - variables?.lab_treeViewEnabled ?? - preferences?.threadViewPrefs?.lab_treeViewEnabled, - ) - - return ( - - - - - - Thread Preferences - - - Fine-tune the discussion threads. - - - - - {preferences ? ( - - - - Sort Replies - - - Sort replies to the same post by: - - - setThreadViewPrefs({sort: key})} - initialSelection={preferences?.threadViewPrefs?.sort} - /> - - - - - - Prioritize Your Follows - - - - Show replies by people you follow before all other replies. - - - - setThreadViewPrefs({ - prioritizeFollowedUsers: !prioritizeFollowedUsers, - }) - } - /> - - - - - {' '} - Threaded Mode - - - - Set this setting to "Yes" to show replies in a threaded view. - This is an experimental feature. - - - - setThreadViewPrefs({ - lab_treeViewEnabled: !treeViewEnabled, - }) - } - /> - - - ) : ( - - )} - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - titleSection: { - paddingBottom: 30, - }, - title: { - textAlign: 'center', - marginBottom: 5, - }, - description: { - textAlign: 'center', - paddingHorizontal: 32, - }, - cardsContainer: { - paddingHorizontal: 20, - paddingVertical: 16, - }, - card: { - padding: 16, - borderRadius: 10, - marginBottom: 20, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - backgroundColor: colors.blue3, - }, - btnDesktop: { - marginHorizontal: 'auto', - paddingHorizontal: 80, - }, - btnContainer: { - paddingTop: 20, - }, - dimmed: { - opacity: 0.3, - }, -}) diff --git a/src/view/screens/Settings/Email2FAToggle.tsx b/src/view/screens/Settings/Email2FAToggle.tsx deleted file mode 100644 index f6ed19a212..0000000000 --- a/src/view/screens/Settings/Email2FAToggle.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useModalControls} from '#/state/modals' -import {useAgent, useSession} from '#/state/session' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {useDialogControl} from '#/components/Dialog' -import {DisableEmail2FADialog} from './DisableEmail2FADialog' - -export function Email2FAToggle() { - const {_} = useLingui() - const {currentAccount} = useSession() - const {openModal} = useModalControls() - const disableDialogCtrl = useDialogControl() - const agent = useAgent() - - const enableEmailAuthFactor = React.useCallback(async () => { - if (currentAccount?.email) { - await agent.com.atproto.server.updateEmail({ - email: currentAccount.email, - emailAuthFactor: true, - }) - await agent.resumeSession(agent.session!) - } - }, [currentAccount, agent]) - - const onToggle = React.useCallback(() => { - if (!currentAccount) { - return - } - if (currentAccount.emailAuthFactor) { - disableDialogCtrl.open() - } else { - if (!currentAccount.emailConfirmed) { - openModal({ - name: 'verify-email', - onSuccess: enableEmailAuthFactor, - }) - return - } - enableEmailAuthFactor() - } - }, [currentAccount, enableEmailAuthFactor, openModal, disableDialogCtrl]) - - return ( - <> - - - - ) -} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx deleted file mode 100644 index 7ec7b5dce1..0000000000 --- a/src/view/screens/Settings/index.tsx +++ /dev/null @@ -1,1077 +0,0 @@ -import React from 'react' -import { - Platform, - Pressable, - StyleSheet, - TextStyle, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import {setStringAsync} from 'expo-clipboard' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect, useNavigation} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' - -import {appVersion, BUNDLE_DATE, bundleInfo, IS_INTERNAL} from '#/lib/app-info' -import {STATUS_PAGE_URL} from '#/lib/constants' -import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' -import {useCustomPalette} from '#/lib/hooks/useCustomPalette' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {HandIcon, HashtagIcon} from '#/lib/icons' -import {makeProfileLink} from '#/lib/routes/links' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' -import {colors, s} from '#/lib/styles' -import {isNative} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import {clearStorage} from '#/state/persisted' -import { - useInAppBrowser, - useSetInAppBrowser, -} from '#/state/preferences/in-app-browser' -import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' -import {useClearPreferencesMutation} from '#/state/queries/preferences' -import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' -import {useProfileQuery} from '#/state/queries/profile' -import {SessionAccount, useSession, useSessionApi} from '#/state/session' -import {useOnboardingDispatch, useSetMinimalShellMode} from '#/state/shell' -import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {useCloseAllActiveElements} from '#/state/util' -import {AccountDropdownBtn} from '#/view/com/util/AccountDropdownBtn' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {Link, TextLink} from '#/view/com/util/Link' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {ScrollView} from '#/view/com/util/Views' -import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' -import {SettingsScreen as NewSettingsScreen} from '#/screens/Settings/Settings' -import {atoms as a, useTheme} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' -import * as Layout from '#/components/Layout' -import {Email2FAToggle} from './Email2FAToggle' -import {ExportCarDialog} from './ExportCarDialog' - -function SettingsAccountCard({ - account, - pendingDid, - onPressSwitchAccount, -}: { - account: SessionAccount - pendingDid: string | null - onPressSwitchAccount: ( - account: SessionAccount, - logContext: 'Settings', - ) => void -}) { - const pal = usePalette('default') - const {_} = useLingui() - const t = useTheme() - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: account.did}) - const isCurrentAccount = account.did === currentAccount?.did - - const contents = ( - - - - - - - {profile?.displayName || account.handle} - - - {account.handle} - - - - - ) - - return isCurrentAccount ? ( - - {contents} - - ) : ( - onPressSwitchAccount(account, 'Settings') - } - accessibilityRole="button" - accessibilityLabel={_(msg`Switch to ${account.handle}`)} - accessibilityHint={_(msg`Switches the account you are logged in to`)} - activeOpacity={0.8}> - {contents} - - ) -} - -type Props = NativeStackScreenProps -export function SettingsScreen(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacySettingsScreen({}: Props) { - const queryClient = useQueryClient() - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const inAppBrowserPref = useInAppBrowser() - const setUseInAppBrowser = useSetInAppBrowser() - const onboardingDispatch = useOnboardingDispatch() - const navigation = useNavigation() - const {isMobile} = useWebMediaQueries() - const {openModal} = useModalControls() - const {accounts, currentAccount} = useSession() - const {mutate: clearPreferences} = useClearPreferencesMutation() - const {setShowLoggedOut} = useLoggedOutViewControls() - const {logoutEveryAccount} = useSessionApi() - const closeAllActiveElements = useCloseAllActiveElements() - const exportCarControl = useDialogControl() - const birthdayControl = useDialogControl() - const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() - const isSwitchingAccounts = !!pendingDid - - // const primaryBg = useCustomPalette({ - // light: {backgroundColor: colors.blue0}, - // dark: {backgroundColor: colors.blue6}, - // }) - // const primaryText = useCustomPalette({ - // light: {color: colors.blue3}, - // dark: {color: colors.blue2}, - // }) - - const dangerBg = useCustomPalette({ - light: {backgroundColor: colors.red1}, - dark: {backgroundColor: colors.red7}, - }) - const dangerText = useCustomPalette({ - light: {color: colors.red4}, - dark: {color: colors.red2}, - }) - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const onPressAddAccount = React.useCallback(() => { - setShowLoggedOut(true) - closeAllActiveElements() - }, [setShowLoggedOut, closeAllActiveElements]) - - const onPressChangeHandle = React.useCallback(() => { - openModal({ - name: 'change-handle', - onChanged() { - if (currentAccount) { - // refresh my profile - queryClient.invalidateQueries({ - queryKey: RQKEY_PROFILE(currentAccount.did), - }) - } - }, - }) - }, [queryClient, openModal, currentAccount]) - - const onPressExportRepository = React.useCallback(() => { - exportCarControl.open() - }, [exportCarControl]) - - const onPressLanguageSettings = React.useCallback(() => { - navigation.navigate('LanguageSettings') - }, [navigation]) - - const onPressDeleteAccount = React.useCallback(() => { - openModal({name: 'delete-account'}) - }, [openModal]) - - const onPressLogoutEveryAccount = React.useCallback(() => { - logoutEveryAccount('Settings') - }, [logoutEveryAccount]) - - const onPressResetPreferences = React.useCallback(async () => { - clearPreferences() - }, [clearPreferences]) - - const onPressResetOnboarding = React.useCallback(async () => { - navigation.navigate('Home') - onboardingDispatch({type: 'start'}) - Toast.show(_(msg`Onboarding reset`)) - }, [navigation, onboardingDispatch, _]) - - const onPressBuildInfo = React.useCallback(() => { - setStringAsync( - `Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}`, - ) - Toast.show(_(msg`Copied build version to clipboard`)) - }, [_]) - - const openFollowingFeedPreferences = React.useCallback(() => { - navigation.navigate('PreferencesFollowingFeed') - }, [navigation]) - - const openThreadsPreferences = React.useCallback(() => { - navigation.navigate('PreferencesThreads') - }, [navigation]) - - const onPressAppPasswords = React.useCallback(() => { - navigation.navigate('AppPasswords') - }, [navigation]) - - const onPressSystemLog = React.useCallback(() => { - navigation.navigate('Log') - }, [navigation]) - - const onPressStorybook = React.useCallback(() => { - navigation.navigate('Debug') - }, [navigation]) - - const onPressDebugModeration = React.useCallback(() => { - navigation.navigate('DebugMod') - }, [navigation]) - - const onPressSavedFeeds = React.useCallback(() => { - navigation.navigate('SavedFeeds') - }, [navigation]) - - const onPressAccessibilitySettings = React.useCallback(() => { - navigation.navigate('AccessibilitySettings') - }, [navigation]) - - const onPressAppearanceSettings = React.useCallback(() => { - navigation.navigate('AppearanceSettings') - }, [navigation]) - - const onPressBirthday = React.useCallback(() => { - birthdayControl.open() - }, [birthdayControl]) - - const clearAllStorage = React.useCallback(async () => { - await clearStorage() - Toast.show(_(msg`Storage cleared, you need to restart the app now.`)) - }, [_]) - - const deactivateAccountControl = useDialogControl() - const onPressDeactivateAccount = React.useCallback(() => { - deactivateAccountControl.open() - }, [deactivateAccountControl]) - - const {mutate: onPressDeleteChatDeclaration} = useDeleteActorDeclaration() - - return ( - - - - - - - - Settings - - - - - - {currentAccount ? ( - <> - - Account - - - - Email:{' '} - - {currentAccount.emailConfirmed && ( - <> - - - )} - - {currentAccount.email || '(no email)'} - - openModal({name: 'change-email'})}> - - Change - - - - - - Birthday:{' '} - - - - Show - - - - - - {!currentAccount.emailConfirmed && } - - - - Signed in as - - - - - - - - ) : null} - - - {accounts.length > 1 && ( - - - Other accounts - - - - )} - - {accounts - .filter(a => a.did !== currentAccount?.did) - .map(account => ( - - ))} - - - - - - - Add account - - - - - - - - - {accounts.length > 1 ? ( - Sign out of all accounts - ) : ( - Sign out - )} - - - - - - - - Basics - - - - - - - Accessibility - - - - - - - - Appearance - - - - - - - - Languages - - - navigation.navigate('Moderation') - } - accessibilityRole="button" - accessibilityLabel={_(msg`Moderation settings`)} - accessibilityHint={_(msg`Opens moderation settings`)}> - - - - - Moderation - - - - - - - - Following Feed Preferences - - - - - - - - Thread Preferences - - - - - - - - My Saved Feeds - - - navigation.navigate('MessagesSettings') - } - accessibilityRole="button" - accessibilityLabel={_(msg`Chat settings`)} - accessibilityHint={_(msg`Opens chat settings`)}> - - - - - Chat Settings - - - - - - - Privacy - - - navigation.navigate('PreferencesExternalEmbeds') - } - accessibilityRole="button" - accessibilityLabel={_(msg`External media settings`)} - accessibilityHint={_(msg`Opens external embeds settings`)}> - - - - - External Media Preferences - - - - - - - Advanced - - - - - - - App Passwords - - - - - - - - Change Handle - - - {isNative && ( - - setUseInAppBrowser(!inAppBrowserPref)} - /> - - )} - - - Two-factor authentication - - - - - - - Account - - openModal({name: 'change-password'})} - accessibilityRole="button" - accessibilityLabel={_(msg`Change password`)} - accessibilityHint={_( - msg`Opens modal for changing your Bluesky password`, - )}> - - - - - Change Password - - - - - - - - Export My Data - - - - - - - - - Deactivate my account - - - - - - - - - - Delete My Account… - - - - - - System log - - - {__DEV__ ? ( - <> - - - Storybook - - - - - Debug Moderation - - - - - Reset preferences state - - - onPressDeleteChatDeclaration()} - accessibilityRole="button" - accessibilityLabel={_(msg`Delete chat declaration record`)} - accessibilityHint={_(msg`Deletes the chat declaration record`)}> - - Delete chat declaration record - - - - - Reset onboarding state - - - - - Clear all storage data (restart after this) - - - - ) : null} - - - - - Version {appVersion} {bundleInfo} - - - - - - - - - - - - - - ) -} - -function EmailConfirmationNotice() { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const {_} = useLingui() - const {isMobile} = useWebMediaQueries() - const verifyEmailDialogControl = useDialogControl() - - return ( - - - Verify email - - - - verifyEmailDialogControl.open()}> - - - Verify My Email - - - - - Protect your account by verifying your email. - - - - - ) -} - -const styles = StyleSheet.create({ - dimmed: { - opacity: 0.5, - }, - spacer20: { - height: 20, - }, - heading: { - paddingHorizontal: 18, - paddingBottom: 6, - }, - infoLine: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 18, - paddingBottom: 6, - }, - profile: { - flexDirection: 'row', - marginVertical: 6, - borderRadius: 4, - paddingVertical: 10, - paddingHorizontal: 10, - }, - linkCard: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 18, - marginBottom: 1, - }, - linkCardNoIcon: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 20, - paddingHorizontal: 18, - marginBottom: 1, - }, - toggleCard: { - paddingVertical: 8, - paddingHorizontal: 6, - marginBottom: 1, - }, - avi: { - marginRight: 12, - }, - iconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 40, - height: 40, - borderRadius: 30, - marginRight: 12, - }, - buildInfo: { - paddingVertical: 8, - }, - - colorModeText: { - marginLeft: 10, - marginBottom: 6, - }, - - selectableBtns: { - flexDirection: 'row', - }, - - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 14, - backgroundColor: colors.gray1, - }, - toggleBtn: { - paddingHorizontal: 0, - }, - footer: { - flex: 1, - flexDirection: 'row', - paddingLeft: 18, - }, -}) diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 9b34159d79..81855c97d0 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -177,7 +177,7 @@ export function BottomBarWeb() { alignItems: 'center', justifyContent: 'space-between', paddingTop: 14, - paddingBottom: 2, + paddingBottom: 14, paddingLeft: 14, paddingRight: 6, gap: 8, diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 2f5f954274..bb6b8cadd3 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -41,7 +41,7 @@ export function DesktopFeeds() { onPress={() => { setSelectedFeed(feed) navigation.navigate('Home') - if (feed === selectedFeed) { + if (route.name === 'Home' && feed === selectedFeed) { emitSoftReset() } }} diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 84d6994b3a..f554373562 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -32,8 +32,9 @@ function ShellInner() { const navigator = useNavigation() const closeAllActiveElements = useCloseAllActiveElements() const {_} = useLingui() + const showDrawer = !isDesktop && isDrawerOpen - useWebBodyScrollLock(isDrawerOpen) + useWebBodyScrollLock(showDrawer) useComposerKeyboardShortcut() useIntentHandler() @@ -56,7 +57,7 @@ function ShellInner() { - {!isDesktop && isDrawerOpen && ( + {showDrawer && ( { // Only close if press happens outside of the drawer diff --git a/yarn.lock b/yarn.lock index c622dd1bd8..056451d1a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7523,10 +7523,12 @@ dependencies: undici-types "~5.26.4" -"@types/node@^18.16.2": - version "18.17.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.17.6.tgz#0296e9a30b22d2a8fcaa48d3c45afe51474ca55b" - integrity sha512-fGmT/P7z7ecA6bv/ia5DlaWCH4YeZvAQMNpUhrJjtAhOhZfoxS1VLUgU2pdk63efSjQaOJWdXMuAJsws+8I6dg== +"@types/node@^20.14.3": + version "20.17.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.6.tgz#6e4073230c180d3579e8c60141f99efdf5df0081" + integrity sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ== + dependencies: + undici-types "~6.19.2" "@types/parse-json@^4.0.0": version "4.0.0" @@ -19968,16 +19970,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -20086,7 +20079,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -20100,13 +20093,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -20923,6 +20909,11 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + undici@^5.28.2: version "5.28.2" resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.2.tgz#fea200eac65fc7ecaff80a023d1a0543423b4c91" @@ -21835,7 +21826,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -21853,15 +21844,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"