Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 213070d6c9 | |||
| d2c9f3ff92 | |||
| 427f3a8bd7 | |||
| dd8d14e133 | |||
| df3f71286d | |||
| b60629aff8 | |||
| ae97032c41 | |||
| b2e11d3171 | |||
| d7841037f1 | |||
| af31ceb2b3 | |||
| 5ee809e421 | |||
| 7008f61ea4 | |||
| 9e1a16686a | |||
| 2728de03d3 | |||
| b85a7da26b | |||
| 57464a8fe0 | |||
| 9247407c70 | |||
| 9a10af7117 | |||
| b4412f3333 | |||
| 89c93313a3 | |||
| 3aeee1e4aa | |||
| 42abd97f61 | |||
| 2d73c5a24c | |||
| e73d5c6c20 | |||
| d8ea31c1d5 |
@@ -3,8 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- bnewbold/embedr
|
||||
- bnewbold/embedr-rebase
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-2
@@ -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 . .
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ module.exports = function (config) {
|
||||
// hsl(211, 99%, 53%), same as palette.default.brandText
|
||||
primaryColor: '#1083fe',
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <BottomSheetPortalProvider> to use the bottom sheet.',
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<BottomSheetNativeComponent {...props} ref={ref} />
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
export {BottomSheetNativeComponent as BottomSheet} from './BottomSheetNativeComponent'
|
||||
|
||||
@@ -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<any>()
|
||||
|
||||
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<typeof PortalContext>
|
||||
if (!Portal) {
|
||||
throw new Error(
|
||||
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> 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 (
|
||||
<NativeView
|
||||
{...rest}
|
||||
onStateChange={this.onStateChange}
|
||||
ref={this.ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: screenHeight,
|
||||
width: '100%',
|
||||
}}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor,
|
||||
},
|
||||
Platform.OS === 'android' && {
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
},
|
||||
extraStyles,
|
||||
]}>
|
||||
<Portal>
|
||||
<NativeView
|
||||
{...rest}
|
||||
onStateChange={this.onStateChange}
|
||||
ref={this.ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: screenHeight,
|
||||
width: '100%',
|
||||
}}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
<View
|
||||
onLayout={e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
this.updateLayout()
|
||||
}}>
|
||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor,
|
||||
},
|
||||
Platform.OS === 'android' && {
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
},
|
||||
extraStyles,
|
||||
]}>
|
||||
<View
|
||||
onLayout={e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
this.updateLayout()
|
||||
}}>
|
||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</NativeView>
|
||||
</NativeView>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -696,7 +696,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
onStateChange={() => {
|
||||
const routeName = getCurrentRouteName()
|
||||
if (routeName === 'Notifications') {
|
||||
logEvent('router:navigate:notifications:sampled', {})
|
||||
logEvent('router:navigate:notifications', {})
|
||||
}
|
||||
}}
|
||||
onReady={() => {
|
||||
|
||||
+25
-28
@@ -103,35 +103,32 @@ export function ThemeProvider({
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Context.Provider
|
||||
value={React.useMemo<Alf>(
|
||||
() => ({
|
||||
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}
|
||||
</Context.Provider>
|
||||
const value = React.useMemo<Alf>(
|
||||
() => ({
|
||||
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 <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useAlf() {
|
||||
|
||||
@@ -32,7 +32,7 @@ export function Error({
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.h_full_vh,
|
||||
a.align_center,
|
||||
a.gap_5xl,
|
||||
!gtMobile && a.justify_between,
|
||||
|
||||
@@ -283,8 +283,8 @@ export function DescriptionPlaceholder({
|
||||
export type FollowButtonProps = {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderationOpts: ModerationOpts
|
||||
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||
LogEvents['profile:unfollow:sampled']['logContext']
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
} & Partial<ButtonProps>
|
||||
|
||||
export function FollowButton(props: FollowButtonProps) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||
|
||||
import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
|
||||
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
import {useEmail} from '#/lib/hooks/useEmail'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
|
||||
@@ -27,6 +28,7 @@ import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
|
||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
|
||||
|
||||
interface SectionRef {
|
||||
@@ -186,6 +188,9 @@ function Empty() {
|
||||
const followersDialogControl = useDialogControl()
|
||||
const errorDialogControl = useDialogControl()
|
||||
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const verifyEmailControl = useDialogControl()
|
||||
|
||||
const [isGenerating, setIsGenerating] = React.useState(false)
|
||||
|
||||
const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
|
||||
@@ -249,7 +254,13 @@ function Empty() {
|
||||
color="primary"
|
||||
size="small"
|
||||
disabled={isGenerating}
|
||||
onPress={confirmDialogControl.open}
|
||||
onPress={() => {
|
||||
if (needsEmailVerification) {
|
||||
verifyEmailControl.open()
|
||||
} else {
|
||||
confirmDialogControl.open()
|
||||
}
|
||||
}}
|
||||
style={{backgroundColor: 'transparent'}}>
|
||||
<ButtonText style={{color: 'white'}}>
|
||||
<Trans>Make one for me</Trans>
|
||||
@@ -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`)}
|
||||
/>
|
||||
<VerifyEmailDialog
|
||||
reasonText={_(
|
||||
msg`Before creating a starter pack, you must first verify your email.`,
|
||||
)}
|
||||
control={verifyEmailControl}
|
||||
/>
|
||||
</LinearGradientBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export function SubtleWebHover({}: {hover: boolean}) {
|
||||
import {ViewStyleProp} from '#/alf'
|
||||
|
||||
export function SubtleWebHover({}: ViewStyleProp & {hover: boolean}) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
<Inner control={control} setDidVerify={setDidVerify} />
|
||||
<Inner
|
||||
control={control}
|
||||
setDidVerify={setDidVerify}
|
||||
reasonText={reasonText}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
{currentStep === 'StepOne' ? (
|
||||
<>
|
||||
<Trans>
|
||||
You'll receive an email at{' '}
|
||||
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
|
||||
{currentAccount?.email}
|
||||
</Text>{' '}
|
||||
to verify it's you.
|
||||
</Trans>{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={_(msg`Change email address`)}
|
||||
style={[a.text_md, a.leading_snug]}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
control.close(() => {
|
||||
openModal({name: 'change-email'})
|
||||
})
|
||||
return false
|
||||
}}>
|
||||
<Trans>Need to change it?</Trans>
|
||||
</InlineLinkText>
|
||||
{!reasonText ? (
|
||||
<>
|
||||
<Trans>
|
||||
You'll receive an email at{' '}
|
||||
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
|
||||
{currentAccount?.email}
|
||||
</Text>{' '}
|
||||
to verify it's you.
|
||||
</Trans>{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={_(msg`Change email address`)}
|
||||
style={[a.text_md, a.leading_snug]}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
control.close(() => {
|
||||
openModal({name: 'change-email'})
|
||||
})
|
||||
return false
|
||||
}}>
|
||||
<Trans>Need to change it?</Trans>
|
||||
</InlineLinkText>
|
||||
</>
|
||||
) : (
|
||||
reasonText
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
uiStrings[currentStep].message
|
||||
|
||||
@@ -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<NavigationProp>()
|
||||
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 (
|
||||
<Link
|
||||
testID="dmBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Message ${profile.handle}`)}
|
||||
to={`/messages/${convo.id}`}
|
||||
style={[a.justify_center]}
|
||||
onPress={onPress}>
|
||||
<ButtonIcon icon={Message} size="md" />
|
||||
</Link>
|
||||
<>
|
||||
<Button
|
||||
accessibilityRole="button"
|
||||
testID="dmBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Message ${profile.handle}`)}
|
||||
style={[a.justify_center]}
|
||||
onPress={onPress}>
|
||||
<ButtonIcon icon={Message} size="md" />
|
||||
</Button>
|
||||
<VerifyEmailDialog
|
||||
reasonText={_(
|
||||
msg`Before you may message another user, you must first verify your email.`,
|
||||
)}
|
||||
control={verifyEmailControl}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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({
|
||||
<>
|
||||
<FAB
|
||||
testID="newChatFAB"
|
||||
onPress={control.open}
|
||||
onPress={() => {
|
||||
if (needsEmailVerification) {
|
||||
verifyEmailControl.open()
|
||||
} else {
|
||||
control.open()
|
||||
}
|
||||
}}
|
||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New chat`)}
|
||||
@@ -62,6 +73,13 @@ export function NewChat({
|
||||
onSelectChat={onCreateChat}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
|
||||
<VerifyEmailDialog
|
||||
reasonText={_(
|
||||
msg`Before you may message another user, you must first verify your email.`,
|
||||
)}
|
||||
control={verifyEmailControl}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ export function useFollowMethods({
|
||||
logContext,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||
LogEvents['profile:unfollow:sampled']['logContext']
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const requireAuth = useRequireAuth()
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useMediaQuery} from 'react-responsive'
|
||||
|
||||
import {isNative, isNativeTablet} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
export function useWebMediaQueries() {
|
||||
const isDesktop = useMediaQuery({minWidth: 1300})
|
||||
@@ -8,15 +8,6 @@ export function useWebMediaQueries() {
|
||||
const isMobile = useMediaQuery({maxWidth: 800 - 1})
|
||||
const isTabletOrMobile = isMobile || isTablet
|
||||
const isTabletOrDesktop = isDesktop || isTablet
|
||||
if (isNativeTablet) {
|
||||
return {
|
||||
isMobile: false,
|
||||
isTablet: true,
|
||||
isTabletOrMobile: true,
|
||||
isTabletOrDesktop: true,
|
||||
isDesktop: false,
|
||||
}
|
||||
}
|
||||
if (isNative) {
|
||||
return {
|
||||
isMobile: true,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Image} from 'react-native'
|
||||
|
||||
import type {Dimensions} from '#/lib/media/types'
|
||||
|
||||
type CacheStorageItem<T> = {key: string; value: T}
|
||||
const createCache = <T>(cacheSize: number) => ({
|
||||
_storage: [] as CacheStorageItem<T>[],
|
||||
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<Dimensions>(50)
|
||||
const activeRequests: Map<string, Promise<Dimensions>> = new Map()
|
||||
|
||||
export function get(uri: string): Dimensions | undefined {
|
||||
return sizes.get(uri)
|
||||
}
|
||||
|
||||
export function fetch(uri: string): Promise<Dimensions> {
|
||||
const dims = sizes.get(uri)
|
||||
if (dims) {
|
||||
return Promise.resolve(dims)
|
||||
}
|
||||
const activeRequest = activeRequests.get(uri)
|
||||
if (activeRequest) {
|
||||
return activeRequest
|
||||
}
|
||||
const prom = new Promise<Dimensions>((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]
|
||||
}
|
||||
+15
-15
@@ -21,11 +21,11 @@ export type LogEvents = {
|
||||
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'
|
||||
status: 'granted' | 'denied' | 'undetermined'
|
||||
}
|
||||
'state:background:sampled': {
|
||||
'state:background': {
|
||||
secondsActive: number
|
||||
}
|
||||
'state:foreground:sampled': {}
|
||||
'router:navigate:notifications:sampled': {}
|
||||
'state:foreground': {}
|
||||
'router:navigate:notifications': {}
|
||||
'deepLink:referrerReceived': {
|
||||
to: string
|
||||
referrer: string
|
||||
@@ -76,7 +76,7 @@ export type LogEvents = {
|
||||
'onboarding:finished:avatarResult': {
|
||||
avatarResult: 'default' | 'created' | 'uploaded'
|
||||
}
|
||||
'home:feedDisplayed:sampled': {
|
||||
'home:feedDisplayed': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
index: number
|
||||
@@ -87,12 +87,12 @@ export type LogEvents = {
|
||||
| 'desktop-sidebar-click'
|
||||
| 'starter-pack-initial-feed'
|
||||
}
|
||||
'feed:endReached:sampled': {
|
||||
'feed:endReached': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
itemCount: number
|
||||
}
|
||||
'feed:refresh:sampled': {
|
||||
'feed:refresh': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
||||
@@ -103,13 +103,13 @@ export type LogEvents = {
|
||||
'discover:showLess': {
|
||||
feedContext: string
|
||||
}
|
||||
'discover:clickthrough:sampled': {
|
||||
'discover:clickthrough': {
|
||||
count: number
|
||||
}
|
||||
'discover:engaged:sampled': {
|
||||
'discover:engaged': {
|
||||
count: number
|
||||
}
|
||||
'discover:seen:sampled': {
|
||||
'discover:seen': {
|
||||
count: number
|
||||
}
|
||||
|
||||
@@ -132,27 +132,27 @@ export type LogEvents = {
|
||||
postCount: number
|
||||
isReply: boolean
|
||||
}
|
||||
'post:like:sampled': {
|
||||
'post:like': {
|
||||
doesLikerFollowPoster: boolean | undefined
|
||||
doesPosterFollowLiker: boolean | undefined
|
||||
likerClout: number | undefined
|
||||
postClout: number | undefined
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||
}
|
||||
'post:repost:sampled': {
|
||||
'post:repost': {
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||
}
|
||||
'post:unlike:sampled': {
|
||||
'post:unlike': {
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||
}
|
||||
'post:unrepost:sampled': {
|
||||
'post:unrepost': {
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||
}
|
||||
'post:mute': {}
|
||||
'post:unmute': {}
|
||||
'post:pin': {}
|
||||
'post:unpin': {}
|
||||
'profile:follow:sampled': {
|
||||
'profile:follow': {
|
||||
didBecomeMutual: boolean | undefined
|
||||
followeeClout: number | undefined
|
||||
followerClout: number | undefined
|
||||
@@ -169,7 +169,7 @@ export type LogEvents = {
|
||||
| 'FeedInterstitial'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
}
|
||||
'profile:unfollow:sampled': {
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
| 'RecommendedFollowsItem'
|
||||
| 'PostThreadItem'
|
||||
|
||||
@@ -59,6 +59,7 @@ function createStatsigOptions(prefetchUsers: StatsigUser[]) {
|
||||
initTimeoutMs: 1,
|
||||
// Get fresh flags for other accounts as well, if any.
|
||||
prefetchUsers,
|
||||
api: 'https://events.bsky.app/v2',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,51 +90,14 @@ export function toClout(n: number | null | undefined): number | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const DOWNSAMPLE_RATE = 0.99 // 99% likely
|
||||
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
||||
'router:navigate:notifications:sampled',
|
||||
'state:background:sampled',
|
||||
'state:foreground:sampled',
|
||||
'home:feedDisplayed:sampled',
|
||||
'feed:endReached:sampled',
|
||||
'feed:refresh:sampled',
|
||||
'discover:clickthrough:sampled',
|
||||
'discover:engaged:sampled',
|
||||
'discover:seen:sampled',
|
||||
'post:like:sampled',
|
||||
'post:unlike:sampled',
|
||||
'post:repost:sampled',
|
||||
'post:unrepost:sampled',
|
||||
'profile:follow:sampled',
|
||||
'profile:unfollow:sampled',
|
||||
])
|
||||
const isDownsampledSession = Math.random() < DOWNSAMPLE_RATE
|
||||
|
||||
export function logEvent<E extends keyof LogEvents>(
|
||||
eventName: E & string,
|
||||
rawMetadata: LogEvents[E] & FlatJSONRecord,
|
||||
) {
|
||||
try {
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
eventName.endsWith(':sampled') &&
|
||||
!DOWNSAMPLED_EVENTS.has(eventName)
|
||||
) {
|
||||
logger.error(
|
||||
'Did you forget to add ' + eventName + ' to DOWNSAMPLED_EVENTS?',
|
||||
)
|
||||
}
|
||||
|
||||
const isDownsampledEvent = DOWNSAMPLED_EVENTS.has(eventName)
|
||||
if (isDownsampledSession && isDownsampledEvent) {
|
||||
return
|
||||
}
|
||||
const fullMetadata = {
|
||||
...rawMetadata,
|
||||
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
||||
if (isDownsampledEvent) {
|
||||
fullMetadata.downsampleRate = DOWNSAMPLE_RATE.toString()
|
||||
}
|
||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||
if (Statsig.initializeCalled()) {
|
||||
Statsig.logEvent(eventName, null, fullMetadata)
|
||||
@@ -232,13 +196,13 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
lastState = state
|
||||
if (state === 'active') {
|
||||
lastActive = performance.now()
|
||||
logEvent('state:foreground:sampled', {})
|
||||
logEvent('state:foreground', {})
|
||||
} else {
|
||||
let secondsActive = 0
|
||||
if (lastActive != null) {
|
||||
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
||||
lastActive = null
|
||||
logEvent('state:background:sampled', {
|
||||
logEvent('state:background', {
|
||||
secondsActive,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {Platform} from 'react-native'
|
||||
export const isIOS = Platform.OS === 'ios'
|
||||
export const isAndroid = Platform.OS === 'android'
|
||||
export const isNative = isIOS || isAndroid
|
||||
export const isNativeTablet = Platform.OS === 'ios' && Platform.isPad
|
||||
export const devicePlatform = isIOS ? 'ios' : isAndroid ? 'android' : 'web'
|
||||
export const isWeb = !isNative
|
||||
export const isMobileWebMediaQuery = 'only screen and (max-width: 1300px)'
|
||||
|
||||
@@ -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'
|
||||
@@ -161,8 +164,12 @@ function InnerReady({
|
||||
hasScrolled: boolean
|
||||
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const convoState = useConvo()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const recipient = useProfileShadow(recipientUnshadowed)
|
||||
const verifyEmailControl = useDialogControl()
|
||||
const {needsEmailVerification} = useEmail()
|
||||
|
||||
const moderation = React.useMemo(() => {
|
||||
return moderateProfile(recipient, moderationOpts)
|
||||
@@ -179,6 +186,12 @@ function InnerReady({
|
||||
}
|
||||
}, [moderation])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (needsEmailVerification) {
|
||||
verifyEmailControl.open()
|
||||
}
|
||||
}, [needsEmailVerification, verifyEmailControl])
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessagesListHeader
|
||||
@@ -201,6 +214,15 @@ function InnerReady({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<VerifyEmailDialog
|
||||
reasonText={_(
|
||||
msg`Before you may message another user, you must first verify your email.`,
|
||||
)}
|
||||
control={verifyEmailControl}
|
||||
onCloseWithoutVerifying={() => {
|
||||
navigation.navigate('Home')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<TextInput>()
|
||||
|
||||
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}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
@@ -171,7 +179,8 @@ export function MessageInput({
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
onPress={onSubmit}
|
||||
disabled={needsEmailVerification}>
|
||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -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<NavigationProp>()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const aviRef = useAnimatedRef()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
@@ -51,27 +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,
|
||||
@@ -149,12 +170,14 @@ let ProfileHeaderShell = ({
|
||||
styles.avi,
|
||||
profile.associated?.labeler && styles.aviLabeler,
|
||||
]}>
|
||||
<UserAvatar
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
size={90}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
<Animated.View ref={aviRef} collapsable={false}>
|
||||
<UserAvatar
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
size={90}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</GrowableAvatar>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -234,21 +234,21 @@ function flushToStatsig(stats: AggregatedStats | null) {
|
||||
}
|
||||
|
||||
if (stats.clickthroughCount > 0) {
|
||||
logEvent('discover:clickthrough:sampled', {
|
||||
logEvent('discover:clickthrough', {
|
||||
count: stats.clickthroughCount,
|
||||
})
|
||||
stats.clickthroughCount = 0
|
||||
}
|
||||
|
||||
if (stats.engagedCount > 0) {
|
||||
logEvent('discover:engaged:sampled', {
|
||||
logEvent('discover:engaged', {
|
||||
count: stats.engagedCount,
|
||||
})
|
||||
stats.engagedCount = 0
|
||||
}
|
||||
|
||||
if (stats.seenCount > 0) {
|
||||
logEvent('discover:seen:sampled', {
|
||||
logEvent('discover:seen', {
|
||||
count: stats.seenCount,
|
||||
})
|
||||
stats.seenCount = 0
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
+12
-12
@@ -98,8 +98,8 @@ export function useGetPosts() {
|
||||
|
||||
export function usePostLikeMutationQueue(
|
||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||
logContext: LogEvents['post:like:sampled']['logContext'] &
|
||||
LogEvents['post:unlike:sampled']['logContext'],
|
||||
logContext: LogEvents['post:like']['logContext'] &
|
||||
LogEvents['post:unlike']['logContext'],
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
const postUri = post.uri
|
||||
@@ -157,7 +157,7 @@ export function usePostLikeMutationQueue(
|
||||
}
|
||||
|
||||
function usePostLikeMutation(
|
||||
logContext: LogEvents['post:like:sampled']['logContext'],
|
||||
logContext: LogEvents['post:like']['logContext'],
|
||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||
) {
|
||||
const {currentAccount} = useSession()
|
||||
@@ -174,7 +174,7 @@ function usePostLikeMutation(
|
||||
if (currentAccount) {
|
||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||
}
|
||||
logEvent('post:like:sampled', {
|
||||
logEvent('post:like', {
|
||||
logContext,
|
||||
doesPosterFollowLiker: postAuthor.viewer
|
||||
? Boolean(postAuthor.viewer.followedBy)
|
||||
@@ -196,12 +196,12 @@ function usePostLikeMutation(
|
||||
}
|
||||
|
||||
function usePostUnlikeMutation(
|
||||
logContext: LogEvents['post:unlike:sampled']['logContext'],
|
||||
logContext: LogEvents['post:unlike']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
return useMutation<void, Error, {postUri: string; likeUri: string}>({
|
||||
mutationFn: ({likeUri}) => {
|
||||
logEvent('post:unlike:sampled', {logContext})
|
||||
logEvent('post:unlike', {logContext})
|
||||
return agent.deleteLike(likeUri)
|
||||
},
|
||||
})
|
||||
@@ -209,8 +209,8 @@ function usePostUnlikeMutation(
|
||||
|
||||
export function usePostRepostMutationQueue(
|
||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||
logContext: LogEvents['post:repost:sampled']['logContext'] &
|
||||
LogEvents['post:unrepost:sampled']['logContext'],
|
||||
logContext: LogEvents['post:repost']['logContext'] &
|
||||
LogEvents['post:unrepost']['logContext'],
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
const postUri = post.uri
|
||||
@@ -266,7 +266,7 @@ export function usePostRepostMutationQueue(
|
||||
}
|
||||
|
||||
function usePostRepostMutation(
|
||||
logContext: LogEvents['post:repost:sampled']['logContext'],
|
||||
logContext: LogEvents['post:repost']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
return useMutation<
|
||||
@@ -275,19 +275,19 @@ function usePostRepostMutation(
|
||||
{uri: string; cid: string} // the post's uri and cid
|
||||
>({
|
||||
mutationFn: post => {
|
||||
logEvent('post:repost:sampled', {logContext})
|
||||
logEvent('post:repost', {logContext})
|
||||
return agent.repost(post.uri, post.cid)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function usePostUnrepostMutation(
|
||||
logContext: LogEvents['post:unrepost:sampled']['logContext'],
|
||||
logContext: LogEvents['post:unrepost']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
return useMutation<void, Error, {postUri: string; repostUri: string}>({
|
||||
mutationFn: ({repostUri}) => {
|
||||
logEvent('post:unrepost:sampled', {logContext})
|
||||
logEvent('post:unrepost', {logContext})
|
||||
return agent.deleteRepost(repostUri)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -221,8 +221,8 @@ export function useProfileUpdateMutation() {
|
||||
|
||||
export function useProfileFollowMutationQueue(
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||
LogEvents['profile:follow:sampled']['logContext'],
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:follow']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -293,7 +293,7 @@ export function useProfileFollowMutationQueue(
|
||||
}
|
||||
|
||||
function useProfileFollowMutation(
|
||||
logContext: LogEvents['profile:follow:sampled']['logContext'],
|
||||
logContext: LogEvents['profile:follow']['logContext'],
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||
) {
|
||||
const {currentAccount} = useSession()
|
||||
@@ -308,7 +308,7 @@ function useProfileFollowMutation(
|
||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||
}
|
||||
captureAction(ProgressGuideAction.Follow)
|
||||
logEvent('profile:follow:sampled', {
|
||||
logEvent('profile:follow', {
|
||||
logContext,
|
||||
didBecomeMutual: profile.viewer
|
||||
? Boolean(profile.viewer.followedBy)
|
||||
@@ -322,12 +322,12 @@ function useProfileFollowMutation(
|
||||
}
|
||||
|
||||
function useProfileUnfollowMutation(
|
||||
logContext: LogEvents['profile:unfollow:sampled']['logContext'],
|
||||
logContext: LogEvents['profile:unfollow']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
return useMutation<void, Error, {did: string; followUri: string}>({
|
||||
mutationFn: async ({followUri}) => {
|
||||
logEvent('profile:unfollow:sampled', {logContext})
|
||||
logEvent('profile:unfollow', {logContext})
|
||||
return await agent.deleteFollow(followUri)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -41,14 +41,7 @@ export const SplashScreen = ({
|
||||
</View>
|
||||
<View
|
||||
testID="signinOrCreateAccount"
|
||||
style={[
|
||||
a.px_xl,
|
||||
a.gap_md,
|
||||
a.pb_2xl,
|
||||
a.w_full,
|
||||
a.mx_auto,
|
||||
{maxWidth: 450},
|
||||
]}>
|
||||
style={[a.px_xl, a.gap_md, a.pb_2xl]}>
|
||||
<Button
|
||||
testID="createAccountButton"
|
||||
onPress={onPressCreateAccount}
|
||||
|
||||
@@ -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 (
|
||||
<BottomSheetPortalProvider>
|
||||
<VerifyEmailDialog
|
||||
control={emailVerificationControl}
|
||||
onCloseWithoutVerifying={() => {
|
||||
onClose()
|
||||
}}
|
||||
reasonText={_(
|
||||
msg`Before creating a post, you must first verify your email.`,
|
||||
)}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
behavior={isIOS ? 'padding' : 'height'}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function FeedPage({
|
||||
scrollToTop()
|
||||
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
|
||||
setHasNew(false)
|
||||
logEvent('feed:refresh:sampled', {
|
||||
logEvent('feed:refresh', {
|
||||
feedType: feed.split('|')[0],
|
||||
feedUrl: feed,
|
||||
reason: 'soft-reset',
|
||||
@@ -98,7 +98,7 @@ export function FeedPage({
|
||||
scrollToTop()
|
||||
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
|
||||
setHasNew(false)
|
||||
logEvent('feed:refresh:sampled', {
|
||||
logEvent('feed:refresh', {
|
||||
feedType: feed.split('|')[0],
|
||||
feedUrl: feed,
|
||||
reason: 'load-latest',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {TransformsStyle} from 'react-native'
|
||||
import {MeasuredDimensions} from 'react-native-reanimated'
|
||||
|
||||
export type Dimensions = {
|
||||
width: number
|
||||
height: number
|
||||
@@ -18,8 +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
|
||||
>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import React, {useState} from 'react'
|
||||
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
|
||||
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,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import {Image, ImageStyle} from 'expo-image'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
|
||||
import type {
|
||||
Dimensions as ImageDimensions,
|
||||
ImageSource,
|
||||
Transform,
|
||||
} from '../../@types'
|
||||
import {
|
||||
applyRounding,
|
||||
createTransform,
|
||||
@@ -28,8 +31,6 @@ import {
|
||||
TransformMatrix,
|
||||
} from '../../transforms'
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
|
||||
const MIN_SCREEN_ZOOM = 2
|
||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||
|
||||
@@ -40,24 +41,39 @@ type Props = {
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
onLoad: (dims: ImageDimensions) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
showControls: boolean
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
measureSafeArea: () => {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
imageAspect: number | undefined
|
||||
imageDimensions: ImageDimensions | undefined
|
||||
imageStyle: StyleProp<ImageStyle>
|
||||
dismissSwipePan: PanGesture
|
||||
transforms: Readonly<
|
||||
SharedValue<{
|
||||
scaleAndMoveTransform: Transform
|
||||
cropFrameTransform: Transform
|
||||
cropContentTransform: Transform
|
||||
isResting: boolean
|
||||
isHidden: boolean
|
||||
}>
|
||||
>
|
||||
}
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onLoad,
|
||||
isScrollViewBeingDragged,
|
||||
safeAreaRef,
|
||||
measureSafeArea,
|
||||
imageAspect,
|
||||
imageDimensions,
|
||||
imageStyle,
|
||||
dismissSwipePan,
|
||||
transforms,
|
||||
}: Props) => {
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const committedTransform = useSharedValue(initialTransform)
|
||||
@@ -95,19 +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)
|
||||
return {
|
||||
transform: [{translateX}, {translateY: translateY}, {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(
|
||||
@@ -143,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,
|
||||
@@ -154,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.
|
||||
@@ -213,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
|
||||
}
|
||||
|
||||
@@ -257,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)
|
||||
@@ -302,11 +302,6 @@ const ImageItem = ({
|
||||
committedTransform.value = withClampedSpring(finalTransform)
|
||||
})
|
||||
|
||||
const innerStyle = useAnimatedStyle(() => ({
|
||||
width: '100%',
|
||||
aspectRatio: imageAspect,
|
||||
}))
|
||||
|
||||
const composedGesture = isScrollViewBeingDragged
|
||||
? // If the parent is not at rest, provide a no-op gesture.
|
||||
Gesture.Manual()
|
||||
@@ -317,29 +312,105 @@ 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 (
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.View style={imageStyle} renderToHardwareTextureAndroid>
|
||||
<Animated.View
|
||||
ref={containerRef}
|
||||
// Necessary to make opacity work for both children together.
|
||||
renderToHardwareTextureAndroid
|
||||
style={[styles.container, animatedStyle]}>
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
<AnimatedImage
|
||||
contentFit="contain"
|
||||
source={{uri: imageSrc.uri}}
|
||||
placeholderContentFit="contain"
|
||||
placeholder={{uri: imageSrc.thumbUri}}
|
||||
style={[innerStyle, {borderRadius}]}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
cachePolicy="memory"
|
||||
/>
|
||||
<Animated.View
|
||||
ref={containerRef}
|
||||
style={[styles.container]}
|
||||
renderToHardwareTextureAndroid>
|
||||
<Animated.View style={containerStyle}>
|
||||
{showLoader && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color="#FFF"
|
||||
style={styles.loading}
|
||||
/>
|
||||
)}
|
||||
<Animated.View style={imageCropStyle}>
|
||||
<Animated.View style={imageStyle}>
|
||||
<Image
|
||||
contentFit="cover"
|
||||
source={{uri: imageSrc.uri}}
|
||||
placeholderContentFit="cover"
|
||||
placeholder={{uri: imageSrc.thumbUri}}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
onLoad={
|
||||
hasLoaded
|
||||
? undefined
|
||||
: e => {
|
||||
setHasLoaded(true)
|
||||
onLoad({width: e.source.width, height: e.source.height})
|
||||
}
|
||||
}
|
||||
style={{flex: 1, borderRadius}}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
cachePolicy="memory"
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
@@ -358,6 +429,7 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -7,26 +7,28 @@
|
||||
*/
|
||||
|
||||
import React, {useState} from 'react'
|
||||
import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
|
||||
import {ActivityIndicator, StyleSheet} from 'react-native'
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
PanGesture,
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
AnimatedRef,
|
||||
measure,
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||
import {Image, ImageStyle} from 'expo-image'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
import {
|
||||
Dimensions as ImageDimensions,
|
||||
ImageSource,
|
||||
Transform,
|
||||
} from '../../@types'
|
||||
|
||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||
const MIN_SCREEN_ZOOM = 2
|
||||
@@ -36,25 +38,40 @@ type Props = {
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
onLoad: (dims: ImageDimensions) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
showControls: boolean
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
measureSafeArea: () => {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
imageAspect: number | undefined
|
||||
imageDimensions: ImageDimensions | undefined
|
||||
imageStyle: StyleProp<ImageStyle>
|
||||
dismissSwipePan: PanGesture
|
||||
transforms: Readonly<
|
||||
SharedValue<{
|
||||
scaleAndMoveTransform: Transform
|
||||
cropFrameTransform: Transform
|
||||
cropContentTransform: Transform
|
||||
isResting: boolean
|
||||
isHidden: boolean
|
||||
}>
|
||||
>
|
||||
}
|
||||
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onLoad,
|
||||
showControls,
|
||||
safeAreaRef,
|
||||
measureSafeArea,
|
||||
imageAspect,
|
||||
imageDimensions,
|
||||
imageStyle,
|
||||
dismissSwipePan,
|
||||
transforms,
|
||||
}: Props) => {
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
const [scaled, setScaled] = useState(false)
|
||||
@@ -67,16 +84,6 @@ const ImageItem = ({
|
||||
: 1,
|
||||
)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly
|
||||
return {
|
||||
width: screenSize.width,
|
||||
maxHeight: screenSize.height,
|
||||
alignSelf: 'center',
|
||||
aspectRatio: imageAspect,
|
||||
}
|
||||
})
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll(e) {
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
@@ -114,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,
|
||||
@@ -143,9 +147,58 @@ const ImageItem = ({
|
||||
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 (
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.ScrollView
|
||||
@@ -156,22 +209,36 @@ const ImageItem = ({
|
||||
showsVerticalScrollIndicator={false}
|
||||
maximumZoomScale={maxZoomScale}
|
||||
onScroll={scrollHandler}
|
||||
style={containerStyle}
|
||||
bounces={scaled}
|
||||
bouncesZoom={true}
|
||||
style={imageStyle}
|
||||
centerContent>
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
<AnimatedImage
|
||||
contentFit="contain"
|
||||
source={{uri: imageSrc.uri}}
|
||||
placeholderContentFit="contain"
|
||||
placeholder={{uri: imageSrc.thumbUri}}
|
||||
style={[animatedStyle, {borderRadius}]}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
enableLiveTextInteraction={showControls && !scaled}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
{showLoader && (
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
)}
|
||||
<Animated.View style={imageCropStyle}>
|
||||
<Animated.View style={imageStyle}>
|
||||
<Image
|
||||
contentFit="contain"
|
||||
source={{uri: imageSrc.uri}}
|
||||
placeholderContentFit="contain"
|
||||
placeholder={{uri: imageSrc.thumbUri}}
|
||||
style={{flex: 1, borderRadius}}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
enableLiveTextInteraction={showControls && !scaled}
|
||||
accessibilityIgnoresInvertColors
|
||||
onLoad={
|
||||
hasLoaded
|
||||
? undefined
|
||||
: e => {
|
||||
setHasLoaded(true)
|
||||
onLoad({width: e.source.width, height: e.source.height})
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
)
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
// default implementation fallback for web
|
||||
|
||||
import React from 'react'
|
||||
import {ImageStyle, StyleProp, View} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {PanGesture} from 'react-native-gesture-handler'
|
||||
import {AnimatedRef} from 'react-native-reanimated'
|
||||
import {SharedValue} from 'react-native-reanimated'
|
||||
|
||||
import {Dimensions as ImageDimensions, 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<View>
|
||||
measureSafeArea: () => {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
imageAspect: number | undefined
|
||||
imageDimensions: ImageDimensions | undefined
|
||||
imageStyle: StyleProp<ImageStyle>
|
||||
dismissSwipePan: PanGesture
|
||||
transforms: Readonly<
|
||||
SharedValue<{
|
||||
scaleAndMoveTransform: Transform
|
||||
cropFrameTransform: Transform
|
||||
cropContentTransform: Transform
|
||||
isResting: boolean
|
||||
isHidden: boolean
|
||||
}>
|
||||
>
|
||||
}
|
||||
|
||||
const ImageItem = (_props: Props) => {
|
||||
|
||||
@@ -9,44 +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 {useImageDimensions} from '#/lib/media/image-sizes'
|
||||
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,
|
||||
@@ -56,24 +76,72 @@ export default function ImageViewRoot({
|
||||
onPressSave: (uri: string) => void
|
||||
onPressShare: (uri: string) => void
|
||||
}) {
|
||||
'use no memo'
|
||||
const ref = useAnimatedRef<View>()
|
||||
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.
|
||||
<SafeAreaView
|
||||
style={[styles.screen, !lightbox && styles.screenHidden]}
|
||||
style={[styles.screen, !activeLightbox && styles.screenHidden]}
|
||||
edges={EDGES}
|
||||
aria-modal
|
||||
accessibilityViewIsModal
|
||||
aria-hidden={!lightbox}>
|
||||
aria-hidden={!activeLightbox}>
|
||||
<Animated.View ref={ref} style={{flex: 1}} collapsable={false}>
|
||||
{lightbox && (
|
||||
{activeLightbox && (
|
||||
<ImageView
|
||||
key={lightbox.id}
|
||||
lightbox={lightbox}
|
||||
key={activeLightbox.id}
|
||||
lightbox={activeLightbox}
|
||||
onRequestClose={onRequestClose}
|
||||
onPressSave={onPressSave}
|
||||
onPressShare={onPressShare}
|
||||
onFlyAway={onFlyAway}
|
||||
safeAreaRef={ref}
|
||||
openProgress={openProgress}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
@@ -86,13 +154,17 @@ function ImageView({
|
||||
onRequestClose,
|
||||
onPressSave,
|
||||
onPressShare,
|
||||
onFlyAway,
|
||||
safeAreaRef,
|
||||
openProgress,
|
||||
}: {
|
||||
lightbox: Lightbox
|
||||
onRequestClose: () => void
|
||||
onPressSave: (uri: string) => void
|
||||
onPressShare: (uri: string) => void
|
||||
onFlyAway: () => void
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
openProgress: SharedValue<number>
|
||||
}) {
|
||||
const {images, index: initialImageIndex} = lightbox
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
@@ -104,33 +176,41 @@ function ImageView({
|
||||
const isFlyingAway = useSharedValue(false)
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
if (isFlyingAway.value) {
|
||||
if (openProgress.value < 1 || isFlyingAway.value) {
|
||||
return {pointerEvents: 'none'}
|
||||
}
|
||||
return {pointerEvents: 'auto'}
|
||||
})
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const screenSize = measure(safeAreaRef)
|
||||
let opacity = 1
|
||||
if (screenSize) {
|
||||
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,
|
||||
opacity: Math.round(opacity * factor) / factor,
|
||||
}
|
||||
})
|
||||
|
||||
const animatedHeaderStyle = useAnimatedStyle(() => {
|
||||
const show = showControls && dismissSwipeTranslateY.value === 0
|
||||
return {
|
||||
pointerEvents: show ? 'box-none' : 'none',
|
||||
opacity: withClampedSpring(show ? 1 : 0),
|
||||
opacity: withClampedSpring(
|
||||
show && openProgress.value === 1 ? 1 : 0,
|
||||
FAST_SPRING,
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(show ? 0 : -30),
|
||||
translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -140,10 +220,13 @@ function ImageView({
|
||||
return {
|
||||
flexGrow: 1,
|
||||
pointerEvents: show ? 'box-none' : 'none',
|
||||
opacity: withClampedSpring(show ? 1 : 0),
|
||||
opacity: withClampedSpring(
|
||||
show && openProgress.value === 1 ? 1 : 0,
|
||||
FAST_SPRING,
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(show ? 0 : 30),
|
||||
translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -172,7 +255,7 @@ function ImageView({
|
||||
if (isOut && !wasOut) {
|
||||
// Stop the animation from blocking the screen forever.
|
||||
cancelAnimation(dismissSwipeTranslateY)
|
||||
runOnJS(onRequestClose)()
|
||||
onFlyAway()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -209,6 +292,7 @@ function ImageView({
|
||||
isFlyingAway={isFlyingAway}
|
||||
isActive={i === imageIndex}
|
||||
dismissSwipeTranslateY={dismissSwipeTranslateY}
|
||||
openProgress={openProgress}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
@@ -247,6 +331,7 @@ function LightboxImage({
|
||||
isActive,
|
||||
showControls,
|
||||
safeAreaRef,
|
||||
openProgress,
|
||||
dismissSwipeTranslateY,
|
||||
}: {
|
||||
imageSrc: ImageSource
|
||||
@@ -259,11 +344,76 @@ function LightboxImage({
|
||||
isFlyingAway: SharedValue<boolean>
|
||||
showControls: boolean
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
openProgress: SharedValue<number>
|
||||
dismissSwipeTranslateY: SharedValue<number>
|
||||
}) {
|
||||
const [imageAspect, imageDimensions] = useImageDimensions({
|
||||
src: imageSrc.uri,
|
||||
knownDimensions: imageSrc.dimensions,
|
||||
const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(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()
|
||||
@@ -273,14 +423,14 @@ function LightboxImage({
|
||||
.maxPointers(1)
|
||||
.onUpdate(e => {
|
||||
'worklet'
|
||||
if (isFlyingAway.value) {
|
||||
if (openProgress.value !== 1 || isFlyingAway.value) {
|
||||
return
|
||||
}
|
||||
dismissSwipeTranslateY.value = e.translationY
|
||||
})
|
||||
.onEnd(e => {
|
||||
'worklet'
|
||||
if (isFlyingAway.value) {
|
||||
if (openProgress.value !== 1 || isFlyingAway.value) {
|
||||
return
|
||||
}
|
||||
if (Math.abs(e.velocityY) > 1000) {
|
||||
@@ -303,24 +453,20 @@ function LightboxImage({
|
||||
}
|
||||
})
|
||||
|
||||
const imageStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{translateY: dismissSwipeTranslateY.value}],
|
||||
}
|
||||
})
|
||||
return (
|
||||
<ImageItem
|
||||
imageSrc={imageSrc}
|
||||
onTap={onTap}
|
||||
onZoom={onZoom}
|
||||
onRequestClose={onRequestClose}
|
||||
onLoad={setFetchedDims}
|
||||
isScrollViewBeingDragged={isScrollViewBeingDragged}
|
||||
showControls={showControls}
|
||||
safeAreaRef={safeAreaRef}
|
||||
measureSafeArea={measureSafeArea}
|
||||
imageAspect={imageAspect}
|
||||
imageDimensions={imageDimensions}
|
||||
imageStyle={imageStyle}
|
||||
imageDimensions={dims ?? undefined}
|
||||
dismissSwipePan={dismissSwipePan}
|
||||
transforms={transforms}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -476,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})
|
||||
}
|
||||
|
||||
@@ -467,7 +467,12 @@ let FeedItem = ({
|
||||
{item.type === 'feedgen-like' && item.subjectUri ? (
|
||||
<FeedSourceCard
|
||||
feedUri={item.subjectUri}
|
||||
style={[pal.view, pal.border, styles.feedcard]}
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.border,
|
||||
styles.feedcard,
|
||||
]}
|
||||
showLikes
|
||||
/>
|
||||
) : null}
|
||||
@@ -778,7 +783,6 @@ const styles = StyleSheet.create({
|
||||
opacity: 0.8,
|
||||
},
|
||||
feedcard: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
marginTop: 6,
|
||||
|
||||
@@ -15,7 +15,7 @@ const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
|
||||
export interface PagerRef {
|
||||
setPage: (
|
||||
index: number,
|
||||
reason: LogEvents['home:feedDisplayed:sampled']['reason'],
|
||||
reason: LogEvents['home:feedDisplayed']['reason'],
|
||||
) => void
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ interface Props {
|
||||
onPageSelected?: (index: number) => void
|
||||
onPageSelecting?: (
|
||||
index: number,
|
||||
reason: LogEvents['home:feedDisplayed:sampled']['reason'],
|
||||
reason: LogEvents['home:feedDisplayed']['reason'],
|
||||
) => void
|
||||
onPageScrollStateChanged?: (
|
||||
scrollState: 'idle' | 'dragging' | 'settling',
|
||||
@@ -61,7 +61,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setPage: (
|
||||
index: number,
|
||||
reason: LogEvents['home:feedDisplayed:sampled']['reason'],
|
||||
reason: LogEvents['home:feedDisplayed']['reason'],
|
||||
) => {
|
||||
pagerView.current?.setPage(index)
|
||||
onPageSelecting?.(index, reason)
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
onPageSelected?: (index: number) => void
|
||||
onPageSelecting?: (
|
||||
index: number,
|
||||
reason: LogEvents['home:feedDisplayed:sampled']['reason'],
|
||||
reason: LogEvents['home:feedDisplayed']['reason'],
|
||||
) => void
|
||||
}
|
||||
export const Pager = React.forwardRef(function PagerImpl(
|
||||
@@ -38,17 +38,14 @@ export const Pager = React.forwardRef(function PagerImpl(
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setPage: (
|
||||
index: number,
|
||||
reason: LogEvents['home:feedDisplayed:sampled']['reason'],
|
||||
reason: LogEvents['home:feedDisplayed']['reason'],
|
||||
) => {
|
||||
onTabBarSelect(index, reason)
|
||||
},
|
||||
}))
|
||||
|
||||
const onTabBarSelect = React.useCallback(
|
||||
(
|
||||
index: number,
|
||||
reason: LogEvents['home:feedDisplayed:sampled']['reason'],
|
||||
) => {
|
||||
(index: number, reason: LogEvents['home:feedDisplayed']['reason']) => {
|
||||
const scrollY = window.scrollY
|
||||
// We want to determine if the tabbar is already "sticking" at the top (in which
|
||||
// case we should preserve and restore scroll), or if it is somewhere below in the
|
||||
|
||||
@@ -3,7 +3,7 @@ import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {isNative, isNativeTablet} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {PressableWithHover} from '../util/PressableWithHover'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {DraggableScrollView} from './DraggableScrollView'
|
||||
@@ -37,9 +37,8 @@ export function TabBar({
|
||||
() => ({borderBottomColor: indicatorColor || pal.colors.link}),
|
||||
[indicatorColor, pal],
|
||||
)
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const styles =
|
||||
isTabletOrDesktop && !isNativeTablet ? desktopStyles : mobileStyles
|
||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
|
||||
|
||||
useEffect(() => {
|
||||
if (isNative) {
|
||||
@@ -144,7 +143,7 @@ export function TabBar({
|
||||
<View style={[styles.itemInner, selected && indicatorStyle]}>
|
||||
<Text
|
||||
emoji
|
||||
type={isTabletOrDesktop ? 'xl-bold' : 'lg-bold'}
|
||||
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
|
||||
testID={testID ? `${testID}-${item}` : undefined}
|
||||
style={[
|
||||
selected ? pal.text : pal.textLight,
|
||||
|
||||
@@ -403,7 +403,7 @@ let Feed = ({
|
||||
// =
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
logEvent('feed:refresh:sampled', {
|
||||
logEvent('feed:refresh', {
|
||||
feedType: feedType,
|
||||
feedUrl: feed,
|
||||
reason: 'pull-to-refresh',
|
||||
@@ -421,7 +421,7 @@ let Feed = ({
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
|
||||
logEvent('feed:endReached:sampled', {
|
||||
logEvent('feed:endReached', {
|
||||
feedType: feedType,
|
||||
feedUrl: feed,
|
||||
itemCount: feedItems.length,
|
||||
|
||||
@@ -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 (
|
||||
<Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback>
|
||||
<Link
|
||||
style={[styles.viewFullThread]}
|
||||
href={itemHref}
|
||||
asAnchor
|
||||
noFeedback
|
||||
onPointerEnter={onHoverIn}
|
||||
onPointerLeave={onHoverOut}>
|
||||
<SubtleWebHover
|
||||
hover={hover}
|
||||
// adjust position for visual alignment - the actual box has lots of top padding and not much bottom padding -sfn
|
||||
style={{top: 8, bottom: -5}}
|
||||
/>
|
||||
<View style={styles.viewFullThreadDots}>
|
||||
<Svg width="4" height="40">
|
||||
<Line
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import React from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import Animated, {
|
||||
measure,
|
||||
MeasuredDimensions,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
useAnimatedRef,
|
||||
} from 'react-native-reanimated'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -53,6 +60,7 @@ export function ProfileSubpageHeader({
|
||||
const {openLightbox} = useLightboxControls()
|
||||
const pal = usePalette('default')
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const aviRef = useAnimatedRef()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
@@ -66,28 +74,40 @@ export function ProfileSubpageHeader({
|
||||
setDrawerOpen(true)
|
||||
}, [setDrawerOpen])
|
||||
|
||||
const onPressAvi = React.useCallback(() => {
|
||||
if (
|
||||
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
|
||||
) {
|
||||
const _openLightbox = React.useCallback(
|
||||
(uri: string, thumbRect: MeasuredDimensions | null) => {
|
||||
openLightbox({
|
||||
images: [
|
||||
{
|
||||
uri: avatar,
|
||||
thumbUri: avatar,
|
||||
uri,
|
||||
thumbUri: uri,
|
||||
thumbRect,
|
||||
dimensions: {
|
||||
// It's fine if it's actually smaller but we know it's 1:1.
|
||||
height: 1000,
|
||||
width: 1000,
|
||||
},
|
||||
thumbDimensions: null,
|
||||
type: 'rect-avi',
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
thumbDims: null,
|
||||
})
|
||||
},
|
||||
[openLightbox],
|
||||
)
|
||||
|
||||
const onPressAvi = React.useCallback(() => {
|
||||
if (
|
||||
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
|
||||
) {
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(aviRef)
|
||||
runOnJS(_openLightbox)(avatar, rect)
|
||||
})()
|
||||
}
|
||||
}, [openLightbox, avatar])
|
||||
}, [_openLightbox, avatar, aviRef])
|
||||
|
||||
return (
|
||||
<CenteredView style={pal.view}>
|
||||
@@ -135,19 +155,21 @@ export function ProfileSubpageHeader({
|
||||
paddingBottom: 6,
|
||||
paddingHorizontal: isMobile ? 12 : 14,
|
||||
}}>
|
||||
<Pressable
|
||||
testID="headerAviButton"
|
||||
onPress={onPressAvi}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={_(msg`View the avatar`)}
|
||||
accessibilityHint=""
|
||||
style={{width: 58}}>
|
||||
{avatarType === 'starter-pack' ? (
|
||||
<StarterPack width={58} gradient="sky" />
|
||||
) : (
|
||||
<UserAvatar type={avatarType} size={58} avatar={avatar} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Animated.View ref={aviRef} collapsable={false}>
|
||||
<Pressable
|
||||
testID="headerAviButton"
|
||||
onPress={onPressAvi}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={_(msg`View the avatar`)}
|
||||
accessibilityHint=""
|
||||
style={{width: 58}}>
|
||||
{avatarType === 'starter-pack' ? (
|
||||
<StarterPack width={58} gradient="sky" />
|
||||
) : (
|
||||
<UserAvatar type={avatarType} size={58} avatar={avatar} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
<View style={{flex: 1}}>
|
||||
{isLoading ? (
|
||||
<LoadingPlaceholder
|
||||
|
||||
@@ -468,7 +468,7 @@ let Row = function RowImpl<ItemT>({
|
||||
}
|
||||
} else {
|
||||
if (intersectionTimeout.current) {
|
||||
clearTimeout(intersectionTimeout.current)
|
||||
clearTimeout(intersectionTimeout.current as NodeJS.Timeout)
|
||||
intersectionTimeout.current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {NativeScrollEvent} from 'react-native'
|
||||
import {
|
||||
cancelAnimation,
|
||||
interpolate,
|
||||
makeMutable,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
@@ -20,6 +21,18 @@ function clamp(num: number, min: number, max: number) {
|
||||
return Math.min(Math.max(num, min), max)
|
||||
}
|
||||
|
||||
const V0 = makeMutable(
|
||||
withSpring(0, {
|
||||
overshootClamping: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const V1 = makeMutable(
|
||||
withSpring(1, {
|
||||
overshootClamping: true,
|
||||
}),
|
||||
)
|
||||
|
||||
export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
const {headerHeight} = useShellLayout()
|
||||
const {headerMode} = useMinimalShellMode()
|
||||
@@ -31,9 +44,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
(v: boolean) => {
|
||||
'worklet'
|
||||
cancelAnimation(headerMode)
|
||||
headerMode.value = withSpring(v ? 1 : 0, {
|
||||
overshootClamping: true,
|
||||
})
|
||||
headerMode.value = v ? V1.value : V0.value
|
||||
},
|
||||
[headerMode],
|
||||
)
|
||||
|
||||
@@ -49,6 +49,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||
precacheProfile(queryClient, opts.author)
|
||||
}, [queryClient, opts.author])
|
||||
|
||||
const timestampLabel = niceDate(i18n, opts.timestamp)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -115,8 +117,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||
{({timeElapsed}) => (
|
||||
<WebOnlyInlineLinkText
|
||||
to={opts.postHref}
|
||||
label={niceDate(i18n, opts.timestamp)}
|
||||
title={niceDate(i18n, opts.timestamp)}
|
||||
label={timestampLabel}
|
||||
title={timestampLabel}
|
||||
disableMismatchWarning
|
||||
disableUnderline
|
||||
onPress={onBeforePressPost}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {gradients} from '#/lib/styles'
|
||||
import {isNativeTablet, isWeb} from '#/platform/detection'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {ios} from '#/alf'
|
||||
|
||||
export interface FABProps
|
||||
@@ -37,7 +37,7 @@ export function FABInner({testID, icon, onPress, ...props}: FABProps) {
|
||||
styles.outer,
|
||||
size,
|
||||
tabletSpacing,
|
||||
(isMobile || isNativeTablet) && fabMinimalShellTransform,
|
||||
isMobile && fabMinimalShellTransform,
|
||||
]}>
|
||||
<PressableScale
|
||||
testID={testID}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import {DimensionValue, Pressable, View} from 'react-native'
|
||||
import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyEmbedImages} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useImageDimensions} from '#/lib/media/image-sizes'
|
||||
import {Dimensions} from '#/lib/media/types'
|
||||
import type {Dimensions} from '#/lib/media/types'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
@@ -14,30 +14,6 @@ import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/compone
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function useImageAspectRatio({
|
||||
src,
|
||||
knownDimensions,
|
||||
}: {
|
||||
src: string
|
||||
knownDimensions: Dimensions | null
|
||||
}) {
|
||||
const [raw] = useImageDimensions({src, knownDimensions})
|
||||
let constrained: number | undefined
|
||||
let max: number | undefined
|
||||
let isCropped: boolean | undefined
|
||||
if (raw !== undefined) {
|
||||
const ratio = 1 / 2 // max of 1:2 ratio in feeds
|
||||
constrained = Math.max(raw, ratio)
|
||||
max = Math.max(raw, 0.25) // max of 1:4 in thread
|
||||
isCropped = raw < constrained
|
||||
}
|
||||
return {
|
||||
constrained,
|
||||
max,
|
||||
isCropped,
|
||||
}
|
||||
}
|
||||
|
||||
export function ConstrainedImage({
|
||||
aspectRatio,
|
||||
fullBleed,
|
||||
@@ -92,27 +68,44 @@ export function AutoSizedImage({
|
||||
image: AppBskyEmbedImages.ViewImage
|
||||
crop?: 'none' | 'square' | 'constrained'
|
||||
hideBadge?: boolean
|
||||
onPress?: () => void
|
||||
onPress?: (
|
||||
containerRef: AnimatedRef<React.Component<{}, {}, any>>,
|
||||
fetchedDims: Dimensions | null,
|
||||
) => void
|
||||
onLongPress?: () => void
|
||||
onPressIn?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const largeAlt = useLargeAltBadgeEnabled()
|
||||
const {
|
||||
constrained,
|
||||
max,
|
||||
isCropped: rawIsCropped,
|
||||
} = useImageAspectRatio({
|
||||
src: image.thumb,
|
||||
knownDimensions: image.aspectRatio ?? null,
|
||||
})
|
||||
const containerRef = useAnimatedRef()
|
||||
|
||||
const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null)
|
||||
const dims = fetchedDims ?? image.aspectRatio
|
||||
let aspectRatio: number | undefined
|
||||
if (dims) {
|
||||
aspectRatio = dims.width / dims.height
|
||||
if (Number.isNaN(aspectRatio)) {
|
||||
aspectRatio = undefined
|
||||
}
|
||||
}
|
||||
|
||||
let constrained: number | undefined
|
||||
let max: number | undefined
|
||||
let rawIsCropped: boolean | undefined
|
||||
if (aspectRatio !== undefined) {
|
||||
const ratio = 1 / 2 // max of 1:2 ratio in feeds
|
||||
constrained = Math.max(aspectRatio, ratio)
|
||||
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
|
||||
rawIsCropped = aspectRatio < constrained
|
||||
}
|
||||
|
||||
const cropDisabled = crop === 'none'
|
||||
const isCropped = rawIsCropped && !cropDisabled
|
||||
const hasAlt = !!image.alt
|
||||
|
||||
const contents = (
|
||||
<>
|
||||
<Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}>
|
||||
<Image
|
||||
style={[a.w_full, a.h_full]}
|
||||
source={image.thumb}
|
||||
@@ -120,6 +113,13 @@ export function AutoSizedImage({
|
||||
accessibilityIgnoresInvertColors
|
||||
accessibilityLabel={image.alt}
|
||||
accessibilityHint=""
|
||||
onLoad={
|
||||
fetchedDims
|
||||
? undefined
|
||||
: e => {
|
||||
setFetchedDims({width: e.source.width, height: e.source.height})
|
||||
}
|
||||
}
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
@@ -185,13 +185,13 @@ export function AutoSizedImage({
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
if (cropDisabled) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPress={() => onPress?.(containerRef, fetchedDims)}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
// alt here is what screen readers actually use
|
||||
@@ -213,7 +213,7 @@ export function AutoSizedImage({
|
||||
fullBleed={crop === 'square'}
|
||||
aspectRatio={constrained ?? 1}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPress={() => onPress?.(containerRef, fetchedDims)}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
// alt here is what screen readers actually use
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react'
|
||||
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
|
||||
import Animated, {AnimatedRef} from 'react-native-reanimated'
|
||||
import {Image, ImageStyle} from 'expo-image'
|
||||
import {AppBskyEmbedImages} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Dimensions} from '#/lib/media/types'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -19,13 +20,16 @@ interface Props {
|
||||
index: number
|
||||
onPress?: (
|
||||
index: number,
|
||||
containerRef: AnimatedRef<React.Component<{}, {}, any>>,
|
||||
containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => void
|
||||
onLongPress?: EventFunction
|
||||
onPressIn?: EventFunction
|
||||
imageStyle?: StyleProp<ImageStyle>
|
||||
viewContext?: PostEmbedViewContext
|
||||
insetBorderStyle?: StyleProp<ViewStyle>
|
||||
containerRefs: AnimatedRef<React.Component<{}, {}, any>>[]
|
||||
thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]>
|
||||
}
|
||||
|
||||
export function GalleryItem({
|
||||
@@ -37,6 +41,8 @@ export function GalleryItem({
|
||||
onLongPress,
|
||||
viewContext,
|
||||
insetBorderStyle,
|
||||
containerRefs,
|
||||
thumbDimsRef,
|
||||
}: Props) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -45,11 +51,17 @@ export function GalleryItem({
|
||||
const hasAlt = !!image.alt
|
||||
const hideBadges =
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const containerRef = useAnimatedRef()
|
||||
return (
|
||||
<Animated.View style={a.flex_1} ref={containerRef}>
|
||||
<Animated.View
|
||||
style={a.flex_1}
|
||||
ref={containerRefs[index]}
|
||||
collapsable={false}>
|
||||
<Pressable
|
||||
onPress={onPress ? () => onPress(index, containerRef) : undefined}
|
||||
onPress={
|
||||
onPress
|
||||
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
|
||||
: undefined
|
||||
}
|
||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
||||
style={[
|
||||
@@ -68,6 +80,12 @@ export function GalleryItem({
|
||||
accessibilityLabel={image.alt}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
onLoad={e => {
|
||||
thumbDimsRef.current[index] = {
|
||||
width: e.source.width,
|
||||
height: e.source.height,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {AnimatedRef} from 'react-native-reanimated'
|
||||
import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
|
||||
import {AppBskyEmbedImages} from '@atproto/api'
|
||||
|
||||
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Dimensions} from '../../lightbox/ImageViewing/@types'
|
||||
import {GalleryItem} from './Gallery'
|
||||
|
||||
interface ImageLayoutGridProps {
|
||||
images: AppBskyEmbedImages.ViewImage[]
|
||||
onPress?: (
|
||||
index: number,
|
||||
containerRef: AnimatedRef<React.Component<{}, {}, any>>,
|
||||
containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => void
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
@@ -41,7 +43,8 @@ interface ImageLayoutGridInnerProps {
|
||||
images: AppBskyEmbedImages.ViewImage[]
|
||||
onPress?: (
|
||||
index: number,
|
||||
containerRef: AnimatedRef<React.Component<{}, {}, any>>,
|
||||
containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => void
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
@@ -53,8 +56,15 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
const gap = props.gap
|
||||
const count = props.images.length
|
||||
|
||||
const containerRef1 = useAnimatedRef()
|
||||
const containerRef2 = useAnimatedRef()
|
||||
const containerRef3 = useAnimatedRef()
|
||||
const containerRef4 = useAnimatedRef()
|
||||
const thumbDimsRef = React.useRef<(Dimensions | null)[]>([])
|
||||
|
||||
switch (count) {
|
||||
case 2:
|
||||
case 2: {
|
||||
const containerRefs = [containerRef1, containerRef2]
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row, gap]}>
|
||||
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||
@@ -62,6 +72,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
{...props}
|
||||
index={0}
|
||||
insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||
@@ -69,12 +81,16 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
{...props}
|
||||
index={1}
|
||||
insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
case 3:
|
||||
case 3: {
|
||||
const containerRefs = [containerRef1, containerRef2, containerRef3]
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row, gap]}>
|
||||
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||
@@ -82,6 +98,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
{...props}
|
||||
index={0}
|
||||
insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1, {aspectRatio: 1}, gap]}>
|
||||
@@ -94,6 +112,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
'bottomLeft',
|
||||
'bottomRight',
|
||||
])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
@@ -105,13 +125,22 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
'bottomLeft',
|
||||
'topRight',
|
||||
])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
case 4:
|
||||
case 4: {
|
||||
const containerRefs = [
|
||||
containerRef1,
|
||||
containerRef2,
|
||||
containerRef3,
|
||||
containerRef4,
|
||||
]
|
||||
return (
|
||||
<>
|
||||
<View style={[a.flex_row, gap]}>
|
||||
@@ -124,6 +153,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
'topRight',
|
||||
'bottomRight',
|
||||
])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1, {aspectRatio: 1.5}]}>
|
||||
@@ -135,6 +166,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
'bottomLeft',
|
||||
'bottomRight',
|
||||
])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -148,6 +181,8 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
'topRight',
|
||||
'bottomRight',
|
||||
])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1, {aspectRatio: 1.5}]}>
|
||||
@@ -159,11 +194,14 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||
'bottomLeft',
|
||||
'topRight',
|
||||
])}
|
||||
containerRefs={containerRefs}
|
||||
thumbDimsRef={thumbDimsRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Text} from '../text/Text'
|
||||
|
||||
@@ -78,7 +79,9 @@ export const LoggedOutLayout = ({
|
||||
contentContainerStyle={styles.scrollViewContentContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag">
|
||||
<View style={[styles.contentWrapper]}>{children}</View>
|
||||
<View style={[styles.contentWrapper, isWeb && a.my_auto]}>
|
||||
{children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
@@ -95,7 +98,6 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
// @ts-ignore web only
|
||||
height: '100vh',
|
||||
flex: 1,
|
||||
},
|
||||
side: {
|
||||
flex: 1,
|
||||
@@ -112,9 +114,8 @@ const styles = StyleSheet.create({
|
||||
flex: 2,
|
||||
},
|
||||
scrollViewContentContainer: {
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
paddingHorizontal: 40,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
leadinText: {
|
||||
fontSize: 36,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
export const withBreakpoints = <P extends object>(
|
||||
Mobile: React.ComponentType<P>,
|
||||
@@ -8,12 +9,12 @@ export const withBreakpoints = <P extends object>(
|
||||
Desktop: React.ComponentType<P>,
|
||||
): React.FC<P> =>
|
||||
function WithBreakpoints(props: P) {
|
||||
const {isMobile, isTablet} = useWebMediaQueries()
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
|
||||
if (isMobile) {
|
||||
if (isMobile || isNative) {
|
||||
return <Mobile {...props} />
|
||||
}
|
||||
if (isTablet) {
|
||||
if (isTabletOrMobile) {
|
||||
return <Tablet {...props} />
|
||||
}
|
||||
return <Desktop {...props} />
|
||||
|
||||
@@ -11,7 +11,7 @@ import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {isNativeTablet, isWeb} from '#/platform/detection'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
const AnimatedTouchableOpacity =
|
||||
@@ -37,9 +37,7 @@ export function LoadLatestBtn({
|
||||
|
||||
// Adjust height of the fab if we have a session only on mobile web. If we don't have a session, we want to adjust
|
||||
// it on both tablet and mobile since we are showing the bottom bar (see createNativeStackNavigatorWithAuth)
|
||||
const showBottomBar = hasSession
|
||||
? isMobile || isNativeTablet
|
||||
: isTabletOrMobile
|
||||
const showBottomBar = hasSession ? isMobile : isTabletOrMobile
|
||||
|
||||
const bottomPosition = isTablet
|
||||
? {bottom: 50}
|
||||
@@ -53,7 +51,7 @@ export function LoadLatestBtn({
|
||||
(isTallViewport
|
||||
? styles.loadLatestOutOfLine
|
||||
: styles.loadLatestInline),
|
||||
isTablet && !isNativeTablet && styles.loadLatestInline,
|
||||
isTablet && styles.loadLatestInline,
|
||||
pal.borderDark,
|
||||
pal.view,
|
||||
bottomPosition,
|
||||
@@ -83,12 +81,10 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// @ts-ignore web only
|
||||
loadLatestInline: isWeb
|
||||
? {
|
||||
left: 'calc(50vw - 282px)',
|
||||
}
|
||||
: {},
|
||||
loadLatestInline: {
|
||||
// @ts-ignore web only
|
||||
left: 'calc(50vw - 282px)',
|
||||
},
|
||||
loadLatestOutOfLine: {
|
||||
// @ts-ignore web only
|
||||
left: 'calc(50vw - 382px)',
|
||||
|
||||
@@ -6,13 +6,12 @@ import {
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
import {
|
||||
AnimatedRef,
|
||||
measure,
|
||||
MeasuredDimensions,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
useAnimatedRef,
|
||||
} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
@@ -36,6 +35,7 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import * as ListCard from '#/components/ListCard'
|
||||
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
||||
import {Dimensions} from '../../lightbox/ImageViewing/@types'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
@@ -69,7 +69,6 @@ export function PostEmbeds({
|
||||
viewContext?: PostEmbedViewContext
|
||||
}) {
|
||||
const {openLightbox} = useLightboxControls()
|
||||
const containerRef = useAnimatedRef()
|
||||
|
||||
// quote post with media
|
||||
// =
|
||||
@@ -149,25 +148,28 @@ export function PostEmbeds({
|
||||
}))
|
||||
const _openLightbox = (
|
||||
index: number,
|
||||
thumbDims: MeasuredDimensions | null,
|
||||
thumbRects: (MeasuredDimensions | null)[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
openLightbox({
|
||||
images: items.map(item => ({
|
||||
images: items.map((item, i) => ({
|
||||
...item,
|
||||
thumbRect: thumbRects[i] ?? null,
|
||||
thumbDimensions: fetchedDims[i] ?? null,
|
||||
type: 'image',
|
||||
})),
|
||||
index,
|
||||
thumbDims,
|
||||
})
|
||||
}
|
||||
const onPress = (
|
||||
index: number,
|
||||
ref: AnimatedRef<React.Component<{}, {}, any>>,
|
||||
refs: AnimatedRef<React.Component<{}, {}, any>>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
const dims = measure(ref)
|
||||
runOnJS(_openLightbox)(index, dims)
|
||||
const rects = refs.map(ref => (ref ? measure(ref) : null))
|
||||
runOnJS(_openLightbox)(index, rects, fetchedDims)
|
||||
})()
|
||||
}
|
||||
const onPressIn = (_: number) => {
|
||||
@@ -180,7 +182,7 @@ export function PostEmbeds({
|
||||
const image = images[0]
|
||||
return (
|
||||
<ContentHider modui={moderation?.ui('contentMedia')}>
|
||||
<Animated.View ref={containerRef} style={[a.mt_sm, style]}>
|
||||
<View style={[a.mt_sm, style]}>
|
||||
<AutoSizedImage
|
||||
crop={
|
||||
viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
@@ -191,13 +193,15 @@ export function PostEmbeds({
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={() => onPress(0, containerRef)}
|
||||
onPress={(containerRef, dims) =>
|
||||
onPress(0, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {UITextView} from 'react-native-uitextview'
|
||||
import {lh, s} from '#/lib/styles'
|
||||
import {TypographyVariant, useTheme} from '#/lib/ThemeContext'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {applyFonts, useAlf} from '#/alf'
|
||||
import {
|
||||
childHasEmoji,
|
||||
@@ -44,8 +44,6 @@ export function Text({
|
||||
...props
|
||||
}: React.PropsWithChildren<CustomTextProps>) {
|
||||
const theme = useTheme()
|
||||
const typography = theme.typography[type]
|
||||
const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
|
||||
const {fonts} = useAlf()
|
||||
|
||||
if (IS_DEV) {
|
||||
@@ -60,7 +58,10 @@ export function Text({
|
||||
}
|
||||
}
|
||||
|
||||
if (selectable && isIOS) {
|
||||
const textProps = React.useMemo(() => {
|
||||
const typography = theme.typography[type]
|
||||
const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
|
||||
|
||||
const flattened = StyleSheet.flatten([
|
||||
s.black,
|
||||
typography,
|
||||
@@ -74,49 +75,47 @@ export function Text({
|
||||
// @ts-ignore
|
||||
if (flattened.fontSize) {
|
||||
// @ts-ignore
|
||||
flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier
|
||||
flattened.fontSize = Math.round(
|
||||
// @ts-ignore
|
||||
flattened.fontSize * fonts.scaleMultiplier,
|
||||
)
|
||||
}
|
||||
|
||||
const shared = {
|
||||
uiTextView: true,
|
||||
return {
|
||||
uiTextView: selectable && isIOS,
|
||||
selectable,
|
||||
style: flattened,
|
||||
dataSet: isWeb
|
||||
? Object.assign({tooltip: title}, dataSet || {})
|
||||
: undefined,
|
||||
...props,
|
||||
}
|
||||
}, [
|
||||
dataSet,
|
||||
fonts.family,
|
||||
fonts.scaleMultiplier,
|
||||
lineHeight,
|
||||
props,
|
||||
selectable,
|
||||
style,
|
||||
theme,
|
||||
title,
|
||||
type,
|
||||
])
|
||||
|
||||
if (selectable && isIOS) {
|
||||
return (
|
||||
<UITextView {...shared}>
|
||||
{isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
|
||||
<UITextView {...textProps}>
|
||||
{isIOS && emoji
|
||||
? renderChildrenWithEmoji(children, textProps)
|
||||
: children}
|
||||
</UITextView>
|
||||
)
|
||||
}
|
||||
|
||||
const flattened = StyleSheet.flatten([
|
||||
s.black,
|
||||
typography,
|
||||
lineHeightStyle,
|
||||
style,
|
||||
])
|
||||
|
||||
applyFonts(flattened, fonts.family)
|
||||
|
||||
// should always be defined on `typography`
|
||||
// @ts-ignore
|
||||
if (flattened.fontSize) {
|
||||
// @ts-ignore
|
||||
flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier
|
||||
}
|
||||
|
||||
const shared = {
|
||||
selectable,
|
||||
style: flattened,
|
||||
dataSet: Object.assign({tooltip: title}, dataSet || {}),
|
||||
...props,
|
||||
}
|
||||
|
||||
return (
|
||||
<RNText {...shared}>
|
||||
{isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
|
||||
<RNText {...textProps}>
|
||||
{isIOS && emoji ? renderChildrenWithEmoji(children, textProps) : children}
|
||||
</RNText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ function HomeScreenReady({
|
||||
useFocusEffect(
|
||||
useNonReactiveCallback(() => {
|
||||
if (selectedFeed) {
|
||||
logEvent('home:feedDisplayed:sampled', {
|
||||
logEvent('home:feedDisplayed', {
|
||||
index: selectedIndex,
|
||||
feedType: selectedFeed.split('|')[0],
|
||||
feedUrl: selectedFeed,
|
||||
@@ -163,12 +163,9 @@ function HomeScreenReady({
|
||||
)
|
||||
|
||||
const onPageSelecting = React.useCallback(
|
||||
(
|
||||
index: number,
|
||||
reason: LogEvents['home:feedDisplayed:sampled']['reason'],
|
||||
) => {
|
||||
(index: number, reason: LogEvents['home:feedDisplayed']['reason']) => {
|
||||
const feed = allFeeds[index]
|
||||
logEvent('home:feedDisplayed:sampled', {
|
||||
logEvent('home:feedDisplayed', {
|
||||
index,
|
||||
feedType: feed.split('|')[0],
|
||||
feedUrl: feed,
|
||||
|
||||
@@ -2,9 +2,11 @@ import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useEmail} from '#/lib/hooks/useEmail'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
@@ -16,15 +18,20 @@ import {MyLists} from '#/view/com/lists/MyLists'
|
||||
import {Button} from '#/view/com/util/forms/Button'
|
||||
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||
import * as Layout from '#/components/Layout'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
|
||||
export function ListsScreen({}: Props) {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {openModal} = useModalControls()
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const control = useDialogControl()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
@@ -33,6 +40,11 @@ export function ListsScreen({}: Props) {
|
||||
)
|
||||
|
||||
const onPressNewList = React.useCallback(() => {
|
||||
if (needsEmailVerification) {
|
||||
control.open()
|
||||
return
|
||||
}
|
||||
|
||||
openModal({
|
||||
name: 'create-or-edit-list',
|
||||
purpose: 'app.bsky.graph.defs#curatelist',
|
||||
@@ -46,7 +58,7 @@ export function ListsScreen({}: Props) {
|
||||
} catch {}
|
||||
},
|
||||
})
|
||||
}, [openModal, navigation])
|
||||
}, [needsEmailVerification, control, openModal, navigation])
|
||||
|
||||
return (
|
||||
<Layout.Screen testID="listsScreen">
|
||||
@@ -87,6 +99,12 @@ export function ListsScreen({}: Props) {
|
||||
</View>
|
||||
</SimpleViewHeader>
|
||||
<MyLists filter="curate" style={s.flexGrow1} />
|
||||
<VerifyEmailDialog
|
||||
reasonText={_(
|
||||
msg`Before creating a list, you must first verify your email.`,
|
||||
)}
|
||||
control={control}
|
||||
/>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<CommonNavigatorParams, 'ModerationModlists'>
|
||||
export function ModerationModlistsScreen({}: Props) {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
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 (
|
||||
<Layout.Screen testID="moderationModlistsScreen">
|
||||
@@ -83,6 +95,12 @@ export function ModerationModlistsScreen({}: Props) {
|
||||
</View>
|
||||
</SimpleViewHeader>
|
||||
<MyLists filter="mod" style={s.flexGrow1} />
|
||||
<VerifyEmailDialog
|
||||
reasonText={_(
|
||||
msg`Before creating a list, you must first verify your email.`,
|
||||
)}
|
||||
control={control}
|
||||
/>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export function BottomBarWeb() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 14,
|
||||
paddingBottom: 2,
|
||||
paddingBottom: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 6,
|
||||
gap: 8,
|
||||
|
||||
@@ -32,8 +32,9 @@ function ShellInner() {
|
||||
const navigator = useNavigation<NavigationProp>()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {_} = useLingui()
|
||||
const showDrawer = !isDesktop && isDrawerOpen
|
||||
|
||||
useWebBodyScrollLock(isDrawerOpen)
|
||||
useWebBodyScrollLock(showDrawer)
|
||||
useComposerKeyboardShortcut()
|
||||
useIntentHandler()
|
||||
|
||||
@@ -56,7 +57,7 @@ function ShellInner() {
|
||||
<Lightbox />
|
||||
<PortalOutlet />
|
||||
|
||||
{!isDesktop && isDrawerOpen && (
|
||||
{showDrawer && (
|
||||
<TouchableWithoutFeedback
|
||||
onPress={ev => {
|
||||
// Only close if press happens outside of the drawer
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user