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 ? (
+
+
+
+ ) : (
+
+
+ {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.
-
-
- >
- )}
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- paddingBottom: isNative ? 50 : 0,
- paddingHorizontal: 16,
- },
- textInputWrapper: {
- borderRadius: 8,
- flexDirection: 'row',
- alignItems: 'center',
- marginTop: 16,
- marginBottom: 8,
- },
- input: {
- flex: 1,
- width: '100%',
- paddingVertical: 10,
- paddingHorizontal: 8,
- fontSize: 17,
- letterSpacing: 0.25,
- fontWeight: '400',
- borderRadius: 10,
- },
- passwordContainer: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- paddingVertical: 8,
- paddingHorizontal: 16,
- alignItems: 'center',
- borderRadius: 10,
- marginTop: 16,
- marginBottom: 12,
- },
- btnContainer: {
- flexDirection: 'row',
- justifyContent: 'center',
- marginTop: 12,
- },
- btn: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 32,
- paddingHorizontal: 60,
- paddingVertical: 14,
- },
- btnLabel: {
- fontSize: 18,
- },
- groupContent: {
- borderTopWidth: 1,
- flexDirection: 'row',
- alignItems: 'center',
- },
-})
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
deleted file mode 100644
index 2181a94aaf..0000000000
--- a/src/view/com/modals/ChangeHandle.tsx
+++ /dev/null
@@ -1,614 +0,0 @@
-import React, {useState} from 'react'
-import {
- ActivityIndicator,
- StyleSheet,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {setStringAsync} from 'expo-clipboard'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {cleanError} from '#/lib/strings/errors'
-import {createFullHandle, makeValidHandle} from '#/lib/strings/handles'
-import {s} from '#/lib/styles'
-import {useTheme} from '#/lib/ThemeContext'
-import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
-import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
-import {useServiceQuery} from '#/state/queries/service'
-import {SessionAccount, useAgent, useSession} from '#/state/session'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {Button} from '../util/forms/Button'
-import {SelectableBtn} from '../util/forms/SelectableBtn'
-import {Text} from '../util/text/Text'
-import * as Toast from '../util/Toast'
-import {ScrollView, TextInput} from './util'
-
-export const snapPoints = ['100%']
-
-export type Props = {onChanged: () => void}
-
-export function Component(props: Props) {
- const {currentAccount} = useSession()
- const agent = useAgent()
- const {
- isLoading,
- data: serviceInfo,
- error: serviceInfoError,
- } = useServiceQuery(agent.service.toString())
-
- return isLoading || !currentAccount ? (
-
-
-
- ) : serviceInfoError || !serviceInfo ? (
-
- ) : (
-
- )
-}
-
-export function Inner({
- currentAccount,
- serviceInfo,
- onChanged,
-}: Props & {
- currentAccount: SessionAccount
- serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
-}) {
- const {_} = useLingui()
- const pal = usePalette('default')
- const {closeModal} = useModalControls()
- const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} =
- useUpdateHandleMutation()
- const agent = useAgent()
-
- const [error, setError] = useState('')
-
- const [isCustom, setCustom] = React.useState(false)
- const [handle, setHandle] = React.useState('')
- const [canSave, setCanSave] = React.useState(false)
-
- const userDomain = serviceInfo.availableUserDomains?.[0]
-
- // events
- // =
- const onPressCancel = React.useCallback(() => {
- closeModal()
- }, [closeModal])
- const onToggleCustom = React.useCallback(() => {
- // toggle between a provided domain vs a custom one
- setHandle('')
- setCanSave(false)
- setCustom(!isCustom)
- }, [setCustom, isCustom])
- const onPressSave = React.useCallback(async () => {
- if (!userDomain) {
- logger.error(`ChangeHandle: userDomain is undefined`, {
- service: serviceInfo,
- })
- setError(`The service you've selected has no domains configured.`)
- return
- }
-
- try {
- const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
- logger.debug(`Updating handle to ${newHandle}`)
- await updateHandle({
- handle: newHandle,
- })
- await agent.resumeSession(agent.session!)
- closeModal()
- onChanged()
- } catch (err: any) {
- setError(cleanError(err))
- logger.error('Failed to update handle', {handle, message: err})
- } finally {
- }
- }, [
- setError,
- handle,
- userDomain,
- isCustom,
- onChanged,
- closeModal,
- updateHandle,
- serviceInfo,
- agent,
- ])
-
- // rendering
- // =
- return (
-
-
-
-
-
- Cancel
-
-
-
-
- Change Handle
-
-
- {isUpdateHandlePending ? (
-
- ) : canSave ? (
-
-
- Save
-
-
- ) : undefined}
-
-
-
- {error !== '' && (
-
-
-
- )}
-
- {isCustom ? (
-
- ) : (
-
- )}
-
-
- )
-}
-
-/**
- * The form for using a domain allocated by the PDS
- */
-function ProvidedHandleForm({
- userDomain,
- handle,
- isProcessing,
- setHandle,
- onToggleCustom,
- setCanSave,
-}: {
- userDomain: string
- handle: string
- isProcessing: boolean
- setHandle: (v: string) => void
- onToggleCustom: () => void
- setCanSave: (v: boolean) => void
-}) {
- const pal = usePalette('default')
- const theme = useTheme()
- const {_} = useLingui()
-
- // events
- // =
- const onChangeHandle = React.useCallback(
- (v: string) => {
- const newHandle = makeValidHandle(v)
- setHandle(newHandle)
- setCanSave(newHandle.length > 0)
- },
- [setHandle, setCanSave],
- )
-
- // rendering
- // =
- return (
- <>
-
-
-
-
-
-
- Your full handle will be{' '}
-
- @{createFullHandle(handle, userDomain)}
-
-
-
-
-
- I have my own domain
-
-
- >
- )
-}
-
-/**
- * The form for using a custom domain
- */
-function CustomHandleForm({
- currentAccount,
- handle,
- canSave,
- isProcessing,
- setHandle,
- onToggleCustom,
- onPressSave,
- setCanSave,
-}: {
- currentAccount: SessionAccount
- handle: string
- canSave: boolean
- isProcessing: boolean
- setHandle: (v: string) => void
- onToggleCustom: () => void
- onPressSave: () => void
- setCanSave: (v: boolean) => void
-}) {
- const pal = usePalette('default')
- const palSecondary = usePalette('secondary')
- const palError = usePalette('error')
- const theme = useTheme()
- const {_} = useLingui()
- const [isVerifying, setIsVerifying] = React.useState(false)
- const [error, setError] = React.useState('')
- const [isDNSForm, setDNSForm] = React.useState(true)
- const fetchDid = useFetchDid()
- // events
- // =
- const onPressCopy = React.useCallback(() => {
- setStringAsync(isDNSForm ? `did=${currentAccount.did}` : currentAccount.did)
- Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
- }, [currentAccount, isDNSForm, _])
- const onChangeHandle = React.useCallback(
- (v: string) => {
- setHandle(v)
- setCanSave(false)
- },
- [setHandle, setCanSave],
- )
- const onPressVerify = React.useCallback(async () => {
- if (canSave) {
- onPressSave()
- }
- try {
- setIsVerifying(true)
- setError('')
- const did = await fetchDid(handle)
- if (did === currentAccount.did) {
- setCanSave(true)
- } else {
- setError(`Incorrect DID returned (got ${did})`)
- }
- } catch (err: any) {
- setError(cleanError(err))
- logger.error('Failed to verify domain', {handle, error: err})
- } finally {
- setIsVerifying(false)
- }
- }, [
- handle,
- currentAccount,
- setIsVerifying,
- setCanSave,
- setError,
- canSave,
- onPressSave,
- fetchDid,
- ])
-
- // rendering
- // =
- return (
- <>
-
- Enter the domain you want to use
-
-
-
-
-
-
-
-
- setDNSForm(true)}
- accessibilityHint={_(msg`Use the DNS panel`)}
- style={s.flex1}
- />
- setDNSForm(false)}
- accessibilityHint={_(msg`Use a file on your server`)}
- style={s.flex1}
- />
-
-
- {isDNSForm ? (
- <>
-
- Add the following DNS record to your domain:
-
-
-
- Host:
-
-
-
- _atproto
-
-
-
- Type:
-
-
-
- TXT
-
-
-
- Value:
-
-
-
- did={currentAccount.did}
-
-
-
-
- This should create a domain record at:
-
-
- _atproto.{handle}
-
- >
- ) : (
- <>
-
- Upload a text file to:
-
-
-
-
- https://{handle}/.well-known/atproto-did
-
-
-
-
-
- That contains the following:
-
-
-
-
- {currentAccount.did}
-
-
-
- >
- )}
-
-
-
- {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 (
-
+
+
+
)
}
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"