diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/alf/atoms.ts | 3 | ||||
-rw-r--r-- | src/components/SearchError.tsx | 45 | ||||
-rw-r--r-- | src/components/VideoPostCard.tsx | 17 | ||||
-rw-r--r-- | src/components/interstitials/TrendingVideos.tsx | 31 | ||||
-rw-r--r-- | src/lib/hooks/useIntentHandler.ts | 23 | ||||
-rw-r--r-- | src/lib/hooks/useOTAUpdates.ts | 166 | ||||
-rw-r--r-- | src/screens/Search/SearchResults.tsx | 65 | ||||
-rw-r--r-- | src/screens/Settings/Settings.tsx | 16 |
8 files changed, 314 insertions, 52 deletions
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 440ac16ac..572560217 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -67,6 +67,9 @@ export const atoms = { zIndex: 50, }, + overflow_visible: { + overflow: 'visible', + }, overflow_hidden: { overflow: 'hidden', }, diff --git a/src/components/SearchError.tsx b/src/components/SearchError.tsx new file mode 100644 index 000000000..443bbab8f --- /dev/null +++ b/src/components/SearchError.tsx @@ -0,0 +1,45 @@ +import {View} from 'react-native' + +import {usePalette} from '#/lib/hooks/usePalette' +import {atoms as a, useBreakpoints} from '#/alf' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' +import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times' + +export function SearchError({ + title, + children, +}: { + title?: string + children?: React.ReactNode +}) { + const {gtMobile} = useBreakpoints() + const pal = usePalette('default') + + return ( + <Layout.Content> + <View + style={[ + a.align_center, + a.gap_4xl, + a.px_xl, + { + paddingVertical: 150, + }, + ]}> + <TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} /> + <View + style={[ + a.align_center, + {maxWidth: gtMobile ? 394 : 294}, + gtMobile ? a.gap_md : a.gap_sm, + ]}> + <Text style={[a.font_bold, a.text_lg, a.text_center, a.leading_snug]}> + {title} + </Text> + {children} + </View> + </View> + </Layout.Content> + ) +} diff --git a/src/components/VideoPostCard.tsx b/src/components/VideoPostCard.tsx index 191c7b82a..a1bdd29b4 100644 --- a/src/components/VideoPostCard.tsx +++ b/src/components/VideoPostCard.tsx @@ -411,6 +411,7 @@ export function CompactVideoPostCard({ onPressOut={onPressOut} style={[ a.flex_col, + t.atoms.shadow_sm, { alignItems: undefined, justifyContent: undefined, @@ -421,8 +422,10 @@ export function CompactVideoPostCard({ <View style={[ a.justify_center, - a.rounded_md, + a.rounded_lg, a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, { backgroundColor: black, aspectRatio: 9 / 16, @@ -443,6 +446,8 @@ export function CompactVideoPostCard({ a.inset_0, a.justify_center, a.align_center, + a.border, + t.atoms.border_contrast_low, { backgroundColor: 'black', opacity: 0.2, @@ -462,8 +467,10 @@ export function CompactVideoPostCard({ <View style={[ a.justify_center, - a.rounded_md, + a.rounded_lg, a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, { backgroundColor: black, aspectRatio: 9 / 16, @@ -534,11 +541,13 @@ export function CompactVideoPostCardPlaceholder() { const black = getBlackColor(t) return ( - <View style={[a.flex_1]}> + <View style={[a.flex_1, t.atoms.shadow_sm]}> <View style={[ - a.rounded_md, + a.rounded_lg, a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, { backgroundColor: black, aspectRatio: 9 / 16, diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx index fab738b9c..4d59e2fb5 100644 --- a/src/components/interstitials/TrendingVideos.tsx +++ b/src/components/interstitials/TrendingVideos.tsx @@ -16,7 +16,6 @@ import {atoms as a, useGutters, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' import {Link} from '#/components/Link' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' @@ -25,7 +24,7 @@ import { CompactVideoPostCardPlaceholder, } from '#/components/VideoPostCard' -const CARD_WIDTH = 100 +const CARD_WIDTH = 108 const FEED_DESC = `feedgen|${VIDEO_FEED_URI}` const FEED_PARAMS: { @@ -68,9 +67,10 @@ export function TrendingVideos() { return ( <View style={[ - a.pt_lg, + a.pt_sm, a.pb_lg, a.border_t, + a.overflow_hidden, t.atoms.border_contrast_low, t.atoms.bg_contrast_25, ]}> @@ -82,20 +82,17 @@ export function TrendingVideos() { a.align_center, a.justify_between, ]}> - <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}> - <Graph /> - <Text style={[a.text_md, a.font_bold, a.leading_snug]}> - <Trans>Trending Videos</Trans> - </Text> - </View> + <Text style={[a.text_sm, a.font_bold, a.leading_snug]}> + <Trans>Trending Videos</Trans> + </Text> <Button label={_(msg`Dismiss this section`)} size="tiny" - variant="ghost" + variant="solid" color="secondary" - shape="round" + shape="square" onPress={() => trendingPrompt.open()}> - <ButtonIcon icon={X} /> + <ButtonIcon icon={X} size="sm" /> </Button> </View> @@ -104,11 +101,12 @@ export function TrendingVideos() { horizontal showsHorizontalScrollIndicator={false} decelerationRate="fast" - snapToInterval={CARD_WIDTH + a.gap_sm.gap}> + snapToInterval={CARD_WIDTH + a.gap_md.gap} + style={[a.overflow_visible]}> <View style={[ a.flex_row, - a.gap_sm, + a.gap_md, { paddingLeft: gutters.paddingLeft, paddingRight: gutters.paddingRight, @@ -193,8 +191,11 @@ function VideoCards({ a.justify_center, a.align_center, a.flex_1, - a.rounded_md, + a.rounded_lg, + a.border, + t.atoms.border_contrast_low, t.atoms.bg, + t.atoms.shadow_sm, ]}> {({pressed}) => ( <View diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 6b1083aa4..f55217e56 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -1,8 +1,9 @@ import React from 'react' +import {Alert} from 'react-native' import * as Linking from 'expo-linking' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {useSession} from '#/state/session' import {useCloseAllActiveElements} from '#/state/util' @@ -12,8 +13,10 @@ import { } from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {Referrer} from '../../../modules/expo-bluesky-swiss-army' +import {IS_TESTFLIGHT} from '../app-info.web' +import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' -type IntentType = 'compose' | 'verify-email' | 'age-assurance' +type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota' const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ @@ -27,12 +30,13 @@ export function useIntentHandler() { const ageAssuranceRedirectDialogControl = useAgeAssuranceRedirectDialogControl() const {currentAccount} = useSession() + const {tryApplyUpdate} = useApplyPullRequestOTAUpdate() React.useEffect(() => { const handleIncomingURL = (url: string) => { const referrerInfo = Referrer.getReferrerInfo() if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { - logEvent('deepLink:referrerReceived', { + logger.metric('deepLink:referrerReceived', { to: url, referrer: referrerInfo?.referrer, hostname: referrerInfo?.hostname, @@ -92,6 +96,18 @@ export function useIntentHandler() { } return } + case 'apply-ota': { + if (!isNative || !IS_TESTFLIGHT) { + return + } + + const channel = params.get('channel') + if (!channel) { + Alert.alert('Error', 'No channel provided to look for.') + } else { + tryApplyUpdate(channel) + } + } default: { return } @@ -111,6 +127,7 @@ export function useIntentHandler() { verifyEmailIntent, ageAssuranceRedirectDialogControl, currentAccount, + tryApplyUpdate, ]) } diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 731406dce..72f215fa9 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -1,5 +1,5 @@ import React from 'react' -import {Alert, AppState, AppStateStatus} from 'react-native' +import {Alert, AppState, type AppStateStatus} from 'react-native' import {nativeBuildVersion} from 'expo-application' import { checkForUpdateAsync, @@ -29,6 +29,128 @@ async function setExtraParams() { ) } +async function setExtraParamsPullRequest(channel: string) { + await setExtraParamAsync( + isIOS ? 'ios-build-number' : 'android-build-number', + // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is. + // This just ensures it gets passed as a string + `${nativeBuildVersion}`, + ) + await setExtraParamAsync('channel', channel) +} + +async function updateTestflight() { + await setExtraParams() + + const res = await checkForUpdateAsync() + if (res.isAvailable) { + await fetchUpdateAsync() + + Alert.alert( + 'Update Available', + 'A new version of the app is available. Relaunch now?', + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } +} + +export function useApplyPullRequestOTAUpdate() { + const {currentlyRunning} = useUpdates() + const [pending, setPending] = React.useState(false) + const currentChannel = currentlyRunning?.channel + const isCurrentlyRunningPullRequestDeployment = + currentChannel?.startsWith('pull-request') + + const tryApplyUpdate = async (channel: string) => { + setPending(true) + if (currentChannel === channel) { + const res = await checkForUpdateAsync() + if (res.isAvailable) { + logger.debug('Attempting to fetch update...') + await fetchUpdateAsync() + Alert.alert( + 'Deployment Available', + `A new deployment of ${channel} is availalble. Relaunch now?`, + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } else { + Alert.alert( + 'No Deployment Available', + `No new deployments of ${channel} are currently available for your current native build.`, + ) + } + } else { + setExtraParamsPullRequest(channel) + const res = await checkForUpdateAsync() + if (res.isAvailable) { + Alert.alert( + 'Deployment Available', + `A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`, + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } else { + Alert.alert( + 'No Deployment Available', + `No new deployments of ${channel} are currently available for your current native build.`, + ) + } + } + setPending(false) + } + + const revertToEmbedded = async () => { + try { + await updateTestflight() + } catch (e: any) { + logger.error('Internal OTA Update Error', {error: `${e}`}) + } + } + + return { + tryApplyUpdate, + revertToEmbedded, + currentChannel, + isCurrentlyRunningPullRequestDeployment, + pending, + } +} + export function useOTAUpdates() { const shouldReceiveUpdates = isEnabled && !__DEV__ @@ -36,7 +158,8 @@ export function useOTAUpdates() { const lastMinimize = React.useRef(0) const ranInitialCheck = React.useRef(false) const timeout = React.useRef<NodeJS.Timeout>() - const {isUpdatePending} = useUpdates() + const {currentlyRunning, isUpdatePending} = useUpdates() + const currentChannel = currentlyRunning?.channel const setCheckTimeout = React.useCallback(() => { timeout.current = setTimeout(async () => { @@ -60,36 +183,18 @@ export function useOTAUpdates() { const onIsTestFlight = React.useCallback(async () => { try { - await setExtraParams() - - const res = await checkForUpdateAsync() - if (res.isAvailable) { - await fetchUpdateAsync() - - Alert.alert( - 'Update Available', - 'A new version of the app is available. Relaunch now?', - [ - { - text: 'No', - style: 'cancel', - }, - { - text: 'Relaunch', - style: 'default', - onPress: async () => { - await reloadAsync() - }, - }, - ], - ) - } + await updateTestflight() } catch (e: any) { logger.error('Internal OTA Update Error', {error: `${e}`}) } }, []) React.useEffect(() => { + // We don't need to check anything if the current update is a PR update + if (currentChannel?.startsWith('pull-request')) { + return + } + // We use this setTimeout to allow Statsig to initialize before we check for an update // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update @@ -103,12 +208,15 @@ export function useOTAUpdates() { setCheckTimeout() ranInitialCheck.current = true - }, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates]) + }, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates]) // After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available // or B check for an update again. React.useEffect(() => { - if (!isEnabled) return + // We also don't start this timeout if the user is on a pull request update + if (!isEnabled || currentChannel?.startsWith('pull-request')) { + return + } const subscription = AppState.addEventListener( 'change', @@ -138,5 +246,5 @@ export function useOTAUpdates() { clearTimeout(timeout.current) subscription.remove() } - }, [isUpdatePending, setCheckTimeout]) + }, [isUpdatePending, currentChannel, setCheckTimeout]) } diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx index 6b7a582d5..b626c9329 100644 --- a/src/screens/Search/SearchResults.tsx +++ b/src/screens/Search/SearchResults.tsx @@ -4,11 +4,14 @@ import {type AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {usePalette} from '#/lib/hooks/usePalette' import {augmentSearchQuery} from '#/lib/strings/helpers' import {useActorSearch} from '#/state/queries/actor-search' import {usePopularFeedsSearch} from '#/state/queries/feed' import {useSearchPostsQuery} from '#/state/queries/search-posts' import {useSession} from '#/state/session' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' import {Pager} from '#/view/com/pager/Pager' import {TabBar} from '#/view/com/pager/TabBar' import {Post} from '#/view/com/post/Post' @@ -17,6 +20,8 @@ import {List} from '#/view/com/util/List' import {atoms as a, useTheme, web} from '#/alf' import * as FeedCard from '#/components/FeedCard' import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' +import {SearchError} from '#/components/SearchError' import {Text} from '#/components/Typography' let SearchResults = ({ @@ -104,7 +109,15 @@ function Loader() { ) } -function EmptyState({message, error}: {message: string; error?: string}) { +function EmptyState({ + message, + error, + children, +}: { + message: string + error?: string + children?: React.ReactNode +}) { const t = useTheme() return ( @@ -132,6 +145,8 @@ function EmptyState({message, error}: {message: string; error?: string}) { </Text> </> )} + + {children} </View> </View> </Layout.Content> @@ -161,6 +176,7 @@ let SearchScreenPostResults = ({ const {_} = useLingui() const {currentAccount} = useSession() const [isPTR, setIsPTR] = useState(false) + const isLoggedin = Boolean(currentAccount?.did) const augmentedQuery = useMemo(() => { return augmentSearchQuery(query || '', {did: currentAccount?.did}) @@ -177,6 +193,8 @@ let SearchScreenPostResults = ({ hasNextPage, } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) + const pal = usePalette('default') + const t = useTheme() const onPullToRefresh = useCallback(async () => { setIsPTR(true) await refetch() @@ -216,6 +234,51 @@ let SearchScreenPostResults = ({ return temp }, [posts, isFetchingNextPage]) + const closeAllActiveElements = useCloseAllActiveElements() + const {requestSwitchToAccount} = useLoggedOutViewControls() + + const showSignIn = () => { + closeAllActiveElements() + requestSwitchToAccount({requestedAccount: 'none'}) + } + + const showCreateAccount = () => { + closeAllActiveElements() + requestSwitchToAccount({requestedAccount: 'new'}) + } + + if (!isLoggedin) { + return ( + <SearchError + title={_(msg`Search is currently unavailable when logged out`)}> + <Text style={[a.text_md, a.text_center, a.leading_snug]}> + <Trans> + <InlineLinkText + style={[pal.link]} + label={_(msg`sign in`)} + to={'#'} + onPress={showSignIn}> + Sign in + </InlineLinkText> + <Text style={t.atoms.text_contrast_medium}> or </Text> + <InlineLinkText + style={[pal.link]} + label={_(msg`create an account`)} + to={'#'} + onPress={showCreateAccount}> + create an account + </InlineLinkText> + <Text> </Text> + <Text style={t.atoms.text_contrast_medium}> + to search for news, sports, politics, and everything else + happening on Bluesky. + </Text> + </Trans> + </Text> + </SearchError> + ) + } + return error ? ( <EmptyState message={_( diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 4d10a9d0d..9596c2479 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -12,12 +12,14 @@ import {useActorStatus} from '#/lib/actor-status' import {IS_INTERNAL} from '#/lib/app-info' import {HELP_DESK_URL} from '#/lib/constants' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' +import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates' import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' +import {isNative} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import * as persisted from '#/state/persisted' import {clearStorage} from '#/state/persisted' @@ -364,6 +366,11 @@ function DevOptions() { const onboardingDispatch = useOnboardingDispatch() const navigation = useNavigation<NavigationProp>() const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration() + const { + revertToEmbedded, + isCurrentlyRunningPullRequestDeployment, + currentChannel, + } = useApplyPullRequestOTAUpdate() const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged() const resetOnboarding = async () => { @@ -452,6 +459,15 @@ function DevOptions() { <Trans>Clear all storage data (restart after this)</Trans> </SettingsList.ItemText> </SettingsList.PressableItem> + {isNative && isCurrentlyRunningPullRequestDeployment ? ( + <SettingsList.PressableItem + onPress={revertToEmbedded} + label={_(msg`Unapply Pull Request`)}> + <SettingsList.ItemText> + <Trans>Unapply Pull Request {currentChannel}</Trans> + </SettingsList.ItemText> + </SettingsList.PressableItem> + ) : null} </> ) } |