diff --git a/__tests__/lib/__mocks__/exampleComHtml.ts b/__tests__/lib/__mocks__/exampleComHtml.ts deleted file mode 100644 index 6633e40ca5..0000000000 --- a/__tests__/lib/__mocks__/exampleComHtml.ts +++ /dev/null @@ -1,47 +0,0 @@ -export const exampleComHtml = ` - - - Example Domain - - - - - - - - - -
-

Example Domain

-

This domain is for use in illustrative examples in documents. You may use this - domain in literature without prior coordination or asking for permission.

-

More information...

-
- -` diff --git a/__tests__/lib/__mocks__/tiktokHtml.ts b/__tests__/lib/__mocks__/tiktokHtml.ts deleted file mode 100644 index fa3d112836..0000000000 --- a/__tests__/lib/__mocks__/tiktokHtml.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const tiktokHtml = ` -Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok
Upload

For You

Log in to follow creators, like videos, and view comments.

Suggested accounts

© 2023 TikTok
Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp
00:00/00:00
Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp
_powervision_
Power Vision Tests · 2019-10-19

Related videos

Get TikTok App
-` diff --git a/__tests__/lib/__mocks__/youtubeChannelHtml.ts b/__tests__/lib/__mocks__/youtubeChannelHtml.ts deleted file mode 100644 index cc71995c45..0000000000 --- a/__tests__/lib/__mocks__/youtubeChannelHtml.ts +++ /dev/null @@ -1,63 +0,0 @@ -export const youtubeChannelHtml = ` - -
AboutPressCopyrightContact usCreatorsAdvertiseDevelopersTermsPrivacyPolicy & SafetyHow YouTube worksTest new features
penguinz0 - YouTube
- -` diff --git a/__tests__/lib/__mocks__/youtubeHtml.ts b/__tests__/lib/__mocks__/youtubeHtml.ts deleted file mode 100644 index 7fd9f819da..0000000000 --- a/__tests__/lib/__mocks__/youtubeHtml.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const youtubeHTML = ` - -YouTube
- -` diff --git a/__tests__/lib/errors.test.ts b/__tests__/lib/errors.test.ts index 39e8d189e0..e721396845 100644 --- a/__tests__/lib/errors.test.ts +++ b/__tests__/lib/errors.test.ts @@ -9,11 +9,11 @@ describe('isNetworkError', () => { ] const outputs = [true, false, false, true] - it('correctly distinguishes network errors', () => { - for (let i = 0; i < inputs.length; i++) { - const input = inputs[i] - const result = isNetworkError(input) - expect(result).toEqual(outputs[i]) - } - }) + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i] + const output = outputs[i] + it(`correctly distinguishes network errors for ${input}`, () => { + expect(isNetworkError(input)).toEqual(output) + }) + } }) diff --git a/__tests__/lib/extractHtmlMeta.test.ts b/__tests__/lib/extractHtmlMeta.test.ts deleted file mode 100644 index cdd2a33848..0000000000 --- a/__tests__/lib/extractHtmlMeta.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import {extractHtmlMeta} from '../../src/lib/link-meta/html' -import {exampleComHtml} from './__mocks__/exampleComHtml' -import {youtubeHTML} from './__mocks__/youtubeHtml' -import {tiktokHtml} from './__mocks__/tiktokHtml' -import {youtubeChannelHtml} from './__mocks__/youtubeChannelHtml' - -describe('extractHtmlMeta', () => { - const cases = [ - ['', {}], - ['nothing', {}], - ['title', {title: 'title'}], - [' aSd!@#AC ', {title: 'aSd!@#AC'}], - ['\n title\n ', {title: 'title'}], - ['', {title: 'meta title'}], - [ - '', - {description: 'meta description'}, - ], - ['', {title: 'og title'}], - [ - '', - {description: 'og description'}, - ], - [ - '', - {image: 'https://ogimage.com/foo.png'}, - ], - [ - '', - {title: 'twitter title'}, - ], - [ - '', - {description: 'twitter description'}, - ], - [ - '', - {image: 'https://twitterimage.com/foo.png'}, - ], - ['', {title: 'meta title'}], - ] - - it.each(cases)( - 'given the html tag %p, returns %p', - // @ts-ignore not worth fixing -prf - (input, expectedResult) => { - const output = extractHtmlMeta({html: input as string, hostname: ''}) - expect(output).toEqual(expectedResult) - }, - ) - - it('extracts title and description from a generic HTML page', () => { - const input = exampleComHtml - const expectedOutput = { - title: 'Example Domain', - description: 'An example website', - } - const output = extractHtmlMeta({html: input, hostname: 'example.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts title and description from a Tiktok HTML page', () => { - const input = tiktokHtml - const expectedOutput = { - title: - 'Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok', - description: - '5.5M Likes, 20.8K Comments. TikTok video from Power Vision Tests (@_powervision_): "Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp". оригинальный звук - Power Vision Tests.', - } - const output = extractHtmlMeta({html: input, hostname: 'tiktok.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts title and description from a generic youtube page', () => { - const input = youtubeHTML - const expectedOutput = { - title: 'HD Video (1080p) with Relaxing Music of Native American Shamans', - description: - 'Stunning HD Video ( 1080p ) of Patagonian Nature with Relaxing Native American Shamanic Music. HD footage used from ', - image: 'https://i.ytimg.com/vi/x6UITRjhijI/sddefault.jpg', - } - const output = extractHtmlMeta({html: input, hostname: 'youtube.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts avatar from a youtube channel', () => { - const input = youtubeChannelHtml - const expectedOutput = { - title: 'penguinz0', - description: - 'Clips channel: https://www.youtube.com/channel/UC4EQHfzIbkL_Skit_iKt1aA\n\nTwitter: https://twitter.com/MoistCr1TiKaL\n\nInstagram: https://www.instagram.com/bigmoistcr1tikal/?hl=en\n\nTwitch: https://www.twitch.tv/moistcr1tikal\n\nSnapchat: Hugecharles\n\nTik Tok: Hugecharles\n\nI don't have any other public accounts.', - image: - 'https://yt3.googleusercontent.com/ytc/AL5GRJWOhJOuUC6C2b7gP-5D2q6ypXbcOOckyAE1En4RUQ=s176-c-k-c0x00ffffff-no-rj', - } - const output = extractHtmlMeta({html: input, hostname: 'youtube.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts username from the url a twitter profile page', () => { - const expectedOutput = { - title: '@bluesky on Twitter', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/bluesky', - }) - expect(output).toEqual(expectedOutput) - }) - - it('extracts username from the url a tweet', () => { - const expectedOutput = { - title: 'Tweet by @bluesky', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/bluesky/status/1582437529969917953', - }) - expect(output).toEqual(expectedOutput) - }) - - it("does not extract username from the url when it's not a tweet or profile page", () => { - const expectedOutput = { - title: 'Twitter', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/i/articles/follows/-1675653703?time_window=24', - }) - expect(output).toEqual(expectedOutput) - }) -}) diff --git a/package.json b/package.json index 4b3486545e..5e4d896cce 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "eventemitter3": "^5.0.1", "expo": "^51.0.8", "expo-application": "^5.9.1", + "expo-blur": "^13.0.2", "expo-build-properties": "^0.12.1", "expo-camera": "~15.0.9", "expo-clipboard": "^6.0.3", diff --git a/src/alf/themes.ts b/src/alf/themes.ts index 9f7ec5c673..0cfe09aadc 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -305,6 +305,7 @@ export function createThemes({ } as const const light: Theme = { + scheme: 'light', name: 'light', palette: lightPalette, atoms: { @@ -390,6 +391,7 @@ export function createThemes({ } const dark: Theme = { + scheme: 'dark', name: 'dark', palette: darkPalette, atoms: { @@ -479,6 +481,7 @@ export function createThemes({ const dim: Theme = { ...dark, + scheme: 'dark', name: 'dim', palette: dimPalette, atoms: { diff --git a/src/alf/types.ts b/src/alf/types.ts index 41822b8dd5..08ec593927 100644 --- a/src/alf/types.ts +++ b/src/alf/types.ts @@ -156,6 +156,7 @@ export type ThemedAtoms = { } } export type Theme = { + scheme: 'light' | 'dark' // for library support name: ThemeName palette: Palette atoms: ThemedAtoms diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index 7fb0545a21..00afbdcfe9 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -12,15 +12,15 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' +import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' +import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {NavigationProp} from '#/lib/routes/types' +import {parseStarterPackUri} from '#/lib/strings/starter-pack' import {logger} from '#/logger' -import {useGenerateStarterPackMutation} from 'lib/generate-starterpack' -import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {parseStarterPackUri} from 'lib/strings/starter-pack' -import {List, ListRef} from 'view/com/util/List' -import {Text} from 'view/com/util/text/Text' -import {atoms as a, useTheme} from '#/alf' +import {List, ListRef} from '#/view/com/util/List' +import {Text} from '#/view/com/util/text/Text' +import {atoms as a, ios, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {LinearGradientBackground} from '#/components/LinearGradientBackground' @@ -132,6 +132,7 @@ export const ProfileStarterPacks = React.forwardRef< keyExtractor={keyExtractor} refreshing={isPTRing} headerOffset={headerOffset} + progressViewOffset={ios(0)} contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}} indicatorStyle={t.name === 'light' ? 'black' : 'white'} removeClippedSubviews={true} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 94ee261e38..21928d3df3 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -135,6 +135,8 @@ export function createInput(Component: typeof TextInput) { placeholder, value, onChangeText, + onFocus, + onBlur, isInvalid, inputRef, style, @@ -173,8 +175,14 @@ export function createInput(Component: typeof TextInput) { ref={refs} value={value} onChangeText={onChangeText} - onFocus={ctx.onFocus} - onBlur={ctx.onBlur} + onFocus={e => { + ctx.onFocus() + onFocus?.(e) + }} + onBlur={e => { + ctx.onBlur() + onBlur?.(e) + }} placeholder={placeholder || label} placeholderTextColor={t.palette.contrast_500} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} @@ -188,8 +196,8 @@ export function createInput(Component: typeof TextInput) { a.px_xs, { // paddingVertical doesn't work w/multiline - esb - paddingTop: 14, - paddingBottom: 14, + paddingTop: 12, + paddingBottom: 13, lineHeight: a.text_md.fontSize * 1.1875, textAlignVertical: rest.multiline ? 'top' : undefined, minHeight: rest.multiline ? 80 : undefined, @@ -197,13 +205,14 @@ export function createInput(Component: typeof TextInput) { }, // fix for autofill styles covering border web({ - paddingTop: 12, - paddingBottom: 12, + paddingTop: 10, + paddingBottom: 11, marginTop: 2, marginBottom: 2, }), android({ - paddingBottom: 16, + paddingTop: 8, + paddingBottom: 8, }), style, ]} diff --git a/src/lib/custom-animations/PressableScale.tsx b/src/lib/custom-animations/PressableScale.tsx new file mode 100644 index 0000000000..d6eabf8b22 --- /dev/null +++ b/src/lib/custom-animations/PressableScale.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native' +import Animated, { + cancelAnimation, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' + +import {isTouchDevice} from '#/lib/browser' +import {isNative} from '#/platform/detection' + +const DEFAULT_TARGET_SCALE = isNative || isTouchDevice ? 0.98 : 1 + +export function PressableScale({ + targetScale = DEFAULT_TARGET_SCALE, + children, + contentContainerStyle, + onPressIn, + onPressOut, + ...rest +}: { + targetScale?: number + contentContainerStyle?: StyleProp +} & Exclude) { + const scale = useSharedValue(1) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{scale: scale.value}], + })) + + return ( + { + 'worklet' + if (onPressIn) { + runOnJS(onPressIn)(e) + } + cancelAnimation(scale) + scale.value = withTiming(targetScale, {duration: 100}) + }} + onPressOut={e => { + 'worklet' + if (onPressOut) { + runOnJS(onPressOut)(e) + } + cancelAnimation(scale) + scale.value = withTiming(1, {duration: 100}) + }} + {...rest}> + + {children as React.ReactNode} + + + ) +} diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts index f588808fc3..234be777d3 100644 --- a/src/lib/haptics.ts +++ b/src/lib/haptics.ts @@ -1,8 +1,10 @@ import React from 'react' +import * as Device from 'expo-device' import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics' import {isIOS, isWeb} from '#/platform/detection' import {useHapticsDisabled} from '#/state/preferences/disable-haptics' +import * as Toast from '#/view/com/util/Toast' export function useHaptics() { const isHapticsDisabled = useHapticsDisabled() @@ -18,6 +20,11 @@ export function useHaptics() { ? ImpactFeedbackStyle[strength] : ImpactFeedbackStyle.Light impactAsync(style) + + // DEV ONLY - show a toast when a haptic is meant to fire on simulator + if (__DEV__ && !Device.isDevice) { + Toast.show(`Buzzz!`) + } }, [isHapticsDisabled], ) diff --git a/src/lib/link-meta/html.ts b/src/lib/link-meta/html.ts deleted file mode 100644 index 220f8431d5..0000000000 --- a/src/lib/link-meta/html.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {extractTwitterMeta} from './twitter' -import {extractYoutubeMeta} from './youtube' - -interface ExtractHtmlMetaInput { - html: string - hostname?: string - pathname?: string -} - -export const extractHtmlMeta = ({ - html, - hostname, - pathname, -}: ExtractHtmlMetaInput): Record => { - const htmlTitleRegex = /([^<]+)<\/title>/i - - let res: Record = {} - - const match = htmlTitleRegex.exec(html) - - if (match) { - res.title = match[1].trim() - } - - let metaMatch - let propMatch - const metaRe = /]+)>/gis - while ((metaMatch = metaRe.exec(html))) { - let propName - let propValue - const propRe = /(name|property|content)="([^"]+)"/gis - while ((propMatch = propRe.exec(metaMatch[1]))) { - if (propMatch[1] === 'content') { - propValue = propMatch[2] - } else { - propName = propMatch[2] - } - } - if (!propName || !propValue) { - continue - } - switch (propName?.trim()) { - case 'title': - case 'og:title': - case 'twitter:title': - res.title = propValue?.trim() - break - case 'description': - case 'og:description': - case 'twitter:description': - res.description = propValue?.trim() - break - case 'og:image': - case 'twitter:image': - res.image = propValue?.trim() - break - } - } - - const isYoutubeUrl = - hostname?.includes('youtube.') || hostname?.includes('youtu.be') - const isTwitterUrl = hostname?.includes('twitter.') - // Workaround for some websites not having a title or description in the meta tags in the initial serve - if (isYoutubeUrl) { - res = {...res, ...extractYoutubeMeta(html)} - } else if (isTwitterUrl && pathname) { - res = {...extractTwitterMeta({pathname})} - } - - return res -} diff --git a/src/lib/link-meta/twitter.ts b/src/lib/link-meta/twitter.ts deleted file mode 100644 index d785903c00..0000000000 --- a/src/lib/link-meta/twitter.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const extractTwitterMeta = ({ - pathname, -}: { - pathname: string -}): Record => { - const res = {title: 'Twitter'} - const parsedPathname = pathname.split('/') - if (parsedPathname.length <= 1 || parsedPathname[1].length <= 1) { - // Excluding one letter usernames as they're reserved by twitter for things like cases like twitter.com/i/articles/follows/-1675653703 - return res - } - const username = parsedPathname?.[1] - const isUserProfile = parsedPathname?.length === 2 - - res.title = isUserProfile - ? `@${username} on Twitter` - : `Tweet by @${username}` - - return res -} diff --git a/src/lib/link-meta/youtube.ts b/src/lib/link-meta/youtube.ts deleted file mode 100644 index 42eed51e8f..0000000000 --- a/src/lib/link-meta/youtube.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const extractYoutubeMeta = (html: string): Record => { - const res: Record = {} - const youtubeTitleRegex = /"videoDetails":.*"title":"([^"]*)"/i - const youtubeDescriptionRegex = - /"videoDetails":.*"shortDescription":"([^"]*)"/i - const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i - const youtubeAvatarRegex = - /"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i - const youtubeTitleMatch = youtubeTitleRegex.exec(html) - const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html) - const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html) - const youtubeAvatarMatch = youtubeAvatarRegex.exec(html) - - if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) { - res.title = decodeURI(youtubeTitleMatch[1]) - } - if (youtubeDescriptionMatch && youtubeDescriptionMatch.length >= 1) { - res.description = decodeURI(youtubeDescriptionMatch[1]).replace( - /\\n/g, - '\n', - ) - } - if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) { - res.image = youtubeThumbnailMatch[1] + 'default.jpg' - } - if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) { - res.image = youtubeAvatarMatch[1] - } - - return res -} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 7966767d1b..866d87aef0 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,3 +1,5 @@ export type Gate = // Keep this alphabetic please. - 'debug_show_feedcontext' | 'suggested_feeds_interstitial' + | 'debug_show_feedcontext' + | 'post_feed_lang_window' + | 'suggested_feeds_interstitial' diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts index 899d8ebce4..7d00c5e7f5 100644 --- a/src/lib/strings/errors.ts +++ b/src/lib/strings/errors.ts @@ -20,11 +20,19 @@ export function cleanError(str: any): string { return str } +const NETWORK_ERRORS = [ + 'Abort', + 'Network request failed', + 'Failed to fetch', + 'Load failed', +] + export function isNetworkError(e: unknown) { const str = String(e) - return ( - str.includes('Abort') || - str.includes('Network request failed') || - str.includes('Failed to fetch') - ) + for (const err of NETWORK_ERRORS) { + if (str.includes(err)) { + return true + } + } + return false } diff --git a/src/logger/index.ts b/src/logger/index.ts index d6d8d9fc1d..98635c6a97 100644 --- a/src/logger/index.ts +++ b/src/logger/index.ts @@ -1,10 +1,11 @@ import format from 'date-fns/format' import {nanoid} from 'nanoid/non-secure' -import {Sentry} from '#/logger/sentry' -import * as env from '#/env' import {DebugContext} from '#/logger/debugContext' import {add} from '#/logger/logDump' +import {Sentry} from '#/logger/sentry' +import {isNetworkError} from 'lib/strings/errors' +import * as env from '#/env' export enum LogLevel { Debug = 'debug', @@ -160,6 +161,11 @@ export const sentryTransport: Transport = ( timestamp: timestamp / 1000, // Sentry expects seconds }) + // We don't want to send any network errors to sentry + if (isNetworkError(message)) { + return + } + /** * Send all higher levels with `captureMessage`, with appropriate severity * level diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx index e9668b4e11..11c071082b 100644 --- a/src/screens/Messages/List/ChatListItem.tsx +++ b/src/screens/Messages/List/ChatListItem.tsx @@ -24,8 +24,9 @@ import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' import {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {UserAvatar} from '#/view/com/util/UserAvatar' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import * as tokens from '#/alf/tokens' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' import {Link} from '#/components/Link' @@ -203,6 +204,19 @@ function ChatListItemReady({ onFocus={onFocus} onBlur={onMouseLeave} style={[a.relative]}> + + + + - + {/* Avatar goes here */} + @@ -357,7 +368,7 @@ function ChatListItemReady({ a.self_end, a.justify_center, { - right: a.px_lg.paddingRight, + right: tokens.space.lg, opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, }, ]} diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index e30162c3af..bcc56d7f66 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -5,7 +5,7 @@ import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {Shadow} from '#/state/cache/types' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Text} from '#/components/Typography' export function ProfileHeaderDisplayName({ @@ -16,12 +16,19 @@ export function ProfileHeaderDisplayName({ moderation: ModerationDecision }) { const t = useTheme() + const {gtMobile} = useBreakpoints() + return ( + style={[ + t.atoms.text, + gtMobile ? a.text_4xl : a.text_3xl, + a.self_start, + {fontWeight: '600'}, + ]}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), diff --git a/src/screens/Profile/Header/GrowableAvatar.tsx b/src/screens/Profile/Header/GrowableAvatar.tsx new file mode 100644 index 0000000000..20ac14892d --- /dev/null +++ b/src/screens/Profile/Header/GrowableAvatar.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + SharedValue, + useAnimatedStyle, +} from 'react-native-reanimated' + +import {isIOS} from '#/platform/detection' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' + +export function GrowableAvatar({ + children, + style, +}: { + children: React.ReactNode + style?: StyleProp +}) { + const pagerContext = usePagerHeaderContext() + + // pagerContext should only be present on iOS, but better safe than sorry + if (!pagerContext || !isIOS) { + return {children} + } + + const {scrollY} = pagerContext + + return ( + + {children} + + ) +} + +function GrowableAvatarInner({ + scrollY, + children, + style, +}: { + scrollY: SharedValue + children: React.ReactNode + style?: StyleProp +}) { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate(scrollY.value, [-150, 0], [1.2, 1], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + return ( + + {children} + + ) +} diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx new file mode 100644 index 0000000000..e1bb8e00ef --- /dev/null +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -0,0 +1,212 @@ +import React, {useEffect, useState} from 'react' +import {View} from 'react-native' +import {ActivityIndicator} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + runOnJS, + SharedValue, + useAnimatedProps, + useAnimatedReaction, + useAnimatedStyle, +} from 'react-native-reanimated' +import {BlurView} from 'expo-blur' +import {useIsFetching} from '@tanstack/react-query' + +import {isIOS} from '#/platform/detection' +import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs' +import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed' +import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens' +import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' +import {atoms as a} from '#/alf' + +const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) + +export function GrowableBanner({ + backButton, + children, +}: { + backButton?: React.ReactNode + children: React.ReactNode +}) { + const pagerContext = usePagerHeaderContext() + + // pagerContext should only be present on iOS, but better safe than sorry + if (!pagerContext || !isIOS) { + return ( + + {backButton} + {children} + + ) + } + + const {scrollY} = pagerContext + + return ( + + {children} + + ) +} + +function GrowableBannerInner({ + scrollY, + backButton, + children, +}: { + scrollY: SharedValue + backButton?: React.ReactNode + children: React.ReactNode +}) { + const isFetching = useIsProfileFetching() + const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate(scrollY.value, [-150, 0], [2, 1], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + const animatedBlurViewProps = useAnimatedProps(() => { + return { + intensity: interpolate( + scrollY.value, + [-300, -65, -15], + [50, 40, 0], + Extrapolation.CLAMP, + ), + } + }) + + const animatedSpinnerStyle = useAnimatedStyle(() => { + return { + display: scrollY.value < 0 ? 'flex' : 'none', + opacity: interpolate( + scrollY.value, + [-60, -15], + [1, 0], + Extrapolation.CLAMP, + ), + transform: [ + {translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])}, + {rotate: '90deg'}, + ], + } + }) + + const animatedBackButtonStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + return ( + <> + + {children} + + + + + + + + + {backButton} + + + ) +} + +function useIsProfileFetching() { + // are any of the profile-related queries fetching? + return [ + useIsFetching({queryKey: [FEED_RQKEY_ROOT]}), + useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}), + useIsFetching({queryKey: [LIST_RQKEY_ROOT]}), + useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}), + ].some(isFetching => isFetching) +} + +function useShouldAnimateSpinner({ + isFetching, + scrollY, +}: { + isFetching: boolean + scrollY: SharedValue +}) { + const [isOverscrolled, setIsOverscrolled] = useState(false) + // HACK: it reports a scroll pos of 0 for a tick when fetching finishes + // so paper over that by keeping it true for a bit -sfn + const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10) + + useAnimatedReaction( + () => scrollY.value < -5, + (value, prevValue) => { + if (value !== prevValue) { + runOnJS(setIsOverscrolled)(value) + } + }, + [scrollY], + ) + + const [isAnimating, setIsAnimating] = useState(isFetching) + + if (isFetching && !isAnimating) { + setIsAnimating(true) + } + + if (!isFetching && isAnimating && !stickyIsOverscrolled) { + setIsAnimating(false) + } + + return isAnimating +} + +// stayed true for at least `delay` ms before returning to false +function useStickyToggle(value: boolean, delay: number) { + const [prevValue, setPrevValue] = useState(value) + const [isSticking, setIsSticking] = useState(false) + + useEffect(() => { + if (isSticking) { + const timeout = setTimeout(() => setIsSticking(false), delay) + return () => clearTimeout(timeout) + } + }, [isSticking, delay]) + + if (value !== prevValue) { + setIsSticking(prevValue) // Going true -> false should stick. + setPrevValue(value) + return prevValue ? true : value + } + + return isSticking ? true : value +} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 90c2830907..f7011fd359 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -6,19 +6,21 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' +import {BACK_HITSLOP} from '#/lib/constants' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {NavigationProp} from '#/lib/routes/types' +import {isIOS} from '#/platform/detection' import {Shadow} from '#/state/cache/types' import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox' import {useSession} from '#/state/session' -import {BACK_HITSLOP} from 'lib/constants' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {isIOS} from 'platform/detection' -import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {UserAvatar} from 'view/com/util/UserAvatar' -import {UserBanner} from 'view/com/util/UserBanner' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {UserBanner} from '#/view/com/util/UserBanner' import {atoms as a, useTheme} from '#/alf' import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' +import {GrowableAvatar} from './GrowableAvatar' +import {GrowableBanner} from './GrowableBanner' interface Props { profile: Shadow @@ -63,20 +65,45 @@ let ProfileHeaderShell = ({ return ( - - {isPlaceholderProfile ? ( - - ) : ( - - )} + + + {!isDesktop && !hideBackButton && ( + + + + + + )} + + }> + {isPlaceholderProfile ? ( + + ) : ( + + )} + {children} @@ -93,40 +120,29 @@ let ProfileHeaderShell = ({ )} - {!isDesktop && !hideBackButton && ( + - - + + - )} - - - - - + ) } @@ -144,6 +160,9 @@ const styles = StyleSheet.create({ borderRadius: 15, // @ts-ignore web only cursor: 'pointer', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + alignItems: 'center', + justifyContent: 'center', }, backBtn: { width: 30, @@ -152,10 +171,12 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - avi: { + aviPosition: { position: 'absolute', top: 110, left: 10, + }, + avi: { width: 94, height: 94, borderRadius: 47, diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx index c7ef34b701..cdb0667d06 100644 --- a/src/screens/Profile/Header/index.tsx +++ b/src/screens/Profile/Header/index.tsx @@ -7,18 +7,22 @@ import { RichText as RichTextAPI, } from '@atproto/api' -import {usePalette} from 'lib/hooks/usePalette' -import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {useTheme} from '#/alf' import {ProfileHeaderLabeler} from './ProfileHeaderLabeler' import {ProfileHeaderStandard} from './ProfileHeaderStandard' let ProfileHeaderLoading = (_props: {}): React.ReactNode => { - const pal = usePalette('default') + const t = useTheme() return ( - + + style={[ + t.atoms.bg, + {borderColor: t.atoms.bg.backgroundColor}, + styles.avi, + ]}> diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index fc4eff02c8..22ac5df9a7 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -4,17 +4,18 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {usePalette} from '#/lib/hooks/usePalette' import {isNative} from '#/platform/detection' import {FeedDescriptor} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' -import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' -import {usePalette} from 'lib/hooks/usePalette' +import {Feed} from '#/view/com/posts/Feed' +import {EmptyState} from '#/view/com/util/EmptyState' +import {ListRef} from '#/view/com/util/List' +import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {Text} from '#/view/com/util/text/Text' -import {Feed} from 'view/com/posts/Feed' -import {EmptyState} from 'view/com/util/EmptyState' -import {ListRef} from 'view/com/util/List' -import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {ios} from '#/alf' import {SectionRef} from './types' interface FeedSectionProps { @@ -82,6 +83,7 @@ export const ProfileFeedSection = React.forwardRef< onScrolledDownChange={setIsScrolledDown} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} + progressViewOffset={ios(0)} renderEndOfFeed={ProfileEndOfFeed} ignoreFilterFor={ignoreFilterFor} initialNumToRender={ diff --git a/src/screens/Search/__tests__/utils.test.ts b/src/screens/Search/__tests__/utils.test.ts new file mode 100644 index 0000000000..81610cc59a --- /dev/null +++ b/src/screens/Search/__tests__/utils.test.ts @@ -0,0 +1,43 @@ +import {describe, expect, it} from '@jest/globals' + +import {parseSearchQuery} from '#/screens/Search/utils' + +describe(`parseSearchQuery`, () => { + const tests = [ + { + input: `bluesky`, + output: {query: `bluesky`, params: {}}, + }, + { + input: `bluesky from:esb.lol`, + output: {query: `bluesky`, params: {from: `esb.lol`}}, + }, + { + input: `bluesky "from:esb.lol"`, + output: {query: `bluesky "from:esb.lol"`, params: {}}, + }, + { + input: `bluesky mentions:@esb.lol`, + output: {query: `bluesky`, params: {mentions: `@esb.lol`}}, + }, + { + input: `bluesky since:2021-01-01:00:00:00`, + output: {query: `bluesky`, params: {since: `2021-01-01:00:00:00`}}, + }, + { + input: `bluesky lang:"en"`, + output: {query: `bluesky`, params: {lang: `en`}}, + }, + { + input: `bluesky "literal" lang:en "from:invalid"`, + output: {query: `bluesky "literal" "from:invalid"`, params: {lang: `en`}}, + }, + ] + + it.each(tests)( + `$input -> $output.query $output.params`, + ({input, output}) => { + expect(parseSearchQuery(input)).toEqual(output) + }, + ) +}) diff --git a/src/screens/Search/utils.ts b/src/screens/Search/utils.ts new file mode 100644 index 0000000000..dcf92c0926 --- /dev/null +++ b/src/screens/Search/utils.ts @@ -0,0 +1,43 @@ +export type Params = Record + +export function parseSearchQuery(rawQuery: string) { + let base = rawQuery + const rawLiterals = rawQuery.match(/[^:\w\d]".+?"/gi) || [] + + // remove literals from base + for (const literal of rawLiterals) { + base = base.replace(literal.trim(), '') + } + + // find remaining params in base + const rawParams = base.match(/[a-z]+:[a-z-\.@\d:"]+/gi) || [] + + for (const param of rawParams) { + base = base.replace(param, '') + } + + base = base.trim() + + const params = rawParams.reduce((params, param) => { + const [name, ...value] = param.split(/:/) + params[name] = value.join(':').replace(/"/g, '') // dates can contain additional colons + return params + }, {} as Params) + const literals = rawLiterals.map(l => String(l).trim()) + + return { + query: [base, literals.join(' ')].filter(Boolean).join(' '), + params, + } +} + +export function makeSearchQuery(query: string, params: Params) { + return [ + query, + Object.entries(params) + .map(([name, value]) => `${name}:${value}`) + .join(' '), + ] + .filter(Boolean) + .join(' ') +} diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts index 9de80b07de..487bcdfd99 100644 --- a/src/state/queries/actor-starter-packs.ts +++ b/src/state/queries/actor-starter-packs.ts @@ -6,9 +6,9 @@ import { useInfiniteQuery, } from '@tanstack/react-query' -import {useAgent} from 'state/session' +import {useAgent} from '#/state/session' -const RQKEY_ROOT = 'actor-starter-packs' +export const RQKEY_ROOT = 'actor-starter-packs' export const RQKEY = (did?: string) => [RQKEY_ROOT, did] export function useActorStarterPacksQuery({did}: {did?: string}) { diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 7daf441adb..07c5da81b7 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -15,24 +15,25 @@ import { useInfiniteQuery, } from '@tanstack/react-query' +import {AuthorFeedAPI} from '#/lib/api/feed/author' +import {CustomFeedAPI} from '#/lib/api/feed/custom' +import {FollowingFeedAPI} from '#/lib/api/feed/following' import {HomeFeedAPI} from '#/lib/api/feed/home' +import {LikesFeedAPI} from '#/lib/api/feed/likes' +import {ListFeedAPI} from '#/lib/api/feed/list' +import {MergeFeedAPI} from '#/lib/api/feed/merge' +import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types' import {aggregateUserInterests} from '#/lib/api/feed/utils' +import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip' import {DISCOVER_FEED_URI} from '#/lib/constants' +import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {useAgent} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' -import {AuthorFeedAPI} from 'lib/api/feed/author' -import {CustomFeedAPI} from 'lib/api/feed/custom' -import {FollowingFeedAPI} from 'lib/api/feed/following' -import {LikesFeedAPI} from 'lib/api/feed/likes' -import {ListFeedAPI} from 'lib/api/feed/list' -import {MergeFeedAPI} from 'lib/api/feed/merge' -import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' -import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip' -import {BSKY_FEED_OWNER_DIDS} from 'lib/constants' import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' import {useModerationOpts} from '../preferences/moderation-opts' @@ -65,7 +66,7 @@ export interface FeedParams { type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined -const RQKEY_ROOT = 'post-feed' +export const RQKEY_ROOT = 'post-feed' export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) { return [RQKEY_ROOT, feedDesc, params || {}] } @@ -109,13 +110,19 @@ export interface FeedPage { fetchedAt: number } -const PAGE_SIZE = 30 +/** + * The minimum number of posts we want in a single "page" of results. Since we + * filter out unwanted content, we may fetch more than this number to ensure + * that we get _at least_ this number. + */ +const MIN_POSTS = 30 export function usePostFeedQuery( feedDesc: FeedDescriptor, params?: FeedParams, opts?: {enabled?: boolean; ignoreFilterFor?: string}, ) { + const gate = useGate() const feedTuners = useFeedTuners(feedDesc) const moderationOpts = useModerationOpts() const {data: preferences} = usePreferencesQuery() @@ -135,6 +142,13 @@ export function usePostFeedQuery( } | null>(null) const isDiscover = feedDesc.includes(DISCOVER_FEED_URI) + /** + * The number of posts to fetch in a single request. Because we filter + * unwanted content, we may over-fetch here to try and fill pages by + * `MIN_POSTS`. + */ + const fetchLimit = gate('post_feed_lang_window') ? 100 : MIN_POSTS + // Make sure this doesn't invalidate unless really needed. const selectArgs = React.useMemo( () => ({ @@ -175,7 +189,7 @@ export function usePostFeedQuery( } try { - const res = await api.fetch({cursor, limit: PAGE_SIZE}) + const res = await api.fetch({cursor, limit: fetchLimit}) /* * If this is a public view, we need to check if posts fail moderation. @@ -373,13 +387,13 @@ export function usePostFeedQuery( // Now track how many items we really want, and fetch more if needed. if (isLoading || isRefetching) { // During the initial fetch, we want to get an entire page's worth of items. - wantedItemCount.current = PAGE_SIZE + wantedItemCount.current = MIN_POSTS } else if (isFetchingNextPage) { if (itemCount > wantedItemCount.current) { // We have more items than wantedItemCount, so wantedItemCount must be out of date. // Some other code must have called fetchNextPage(), for example, from onEndReached. // Adjust the wantedItemCount to reflect that we want one more full page of items. - wantedItemCount.current = itemCount + PAGE_SIZE + wantedItemCount.current = itemCount + MIN_POSTS } } else if (hasNextPage) { // At this point we're not fetching anymore, so it's time to make a decision. diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts index b50a2a2890..79d9735c98 100644 --- a/src/state/queries/profile-feedgens.ts +++ b/src/state/queries/profile-feedgens.ts @@ -8,7 +8,7 @@ const PAGE_SIZE = 50 type RQPageParam = string | undefined // TODO refactor invalidate on mutate? -const RQKEY_ROOT = 'profile-feedgens' +export const RQKEY_ROOT = 'profile-feedgens' export const RQKEY = (did: string) => [RQKEY_ROOT, did] export function useProfileFeedgensQuery( diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 03c983ff80..5c9f9f0d6f 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -7,7 +7,7 @@ import {useModerationOpts} from '../preferences/moderation-opts' const PAGE_SIZE = 30 type RQPageParam = string | undefined -const RQKEY_ROOT = 'profile-lists' +export const RQKEY_ROOT = 'profile-lists' export const RQKEY = (did: string) => [RQKEY_ROOT, did] export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 6f98cc49a4..693a8e361a 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -15,9 +15,9 @@ import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {usePreferencesQuery} from '#/state/queries/preferences' import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' +import {EmptyState} from '#/view/com/util/EmptyState' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' -import {EmptyState} from 'view/com/util/EmptyState' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, ios, useTheme} from '#/alf' import * as FeedCard from '#/components/FeedCard' import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' @@ -191,6 +191,7 @@ export const ProfileFeedgens = React.forwardRef< refreshing={isPTRing} onRefresh={onRefresh} headerOffset={headerOffset} + progressViewOffset={ios(0)} contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}} indicatorStyle={t.name === 'light' ? 'black' : 'white'} removeClippedSubviews={true} diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index f633774c7a..117164413f 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -10,14 +10,14 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useAnalytics} from '#/lib/analytics/analytics' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists' -import {useAnalytics} from 'lib/analytics/analytics' +import {EmptyState} from '#/view/com/util/EmptyState' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' -import {EmptyState} from 'view/com/util/EmptyState' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, ios, useTheme} from '#/alf' import * as ListCard from '#/components/ListCard' import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' @@ -192,6 +192,7 @@ export const ProfileLists = React.forwardRef( refreshing={isPTRing} onRefresh={onRefresh} headerOffset={headerOffset} + progressViewOffset={ios(0)} contentContainerStyle={ isNative && {paddingBottom: headerOffset + 100} } diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 5fbaaa1550..3c1f51249f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -625,7 +625,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, layoutIcon: { - width: 70, + width: 60, alignItems: 'flex-end', paddingTop: 2, }, diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx new file mode 100644 index 0000000000..fd4cc74632 --- /dev/null +++ b/src/view/com/pager/PagerHeaderContext.tsx @@ -0,0 +1,41 @@ +import React, {useContext} from 'react' +import {SharedValue} from 'react-native-reanimated' + +import {isIOS} from '#/platform/detection' + +export const PagerHeaderContext = + React.createContext | null>(null) + +/** + * Passes the scrollY value to the pager header's banner, so it can grow on + * overscroll on iOS. Not necessary to use this context provider on other platforms. + * + * @platform ios + */ +export function PagerHeaderProvider({ + scrollY, + children, +}: { + scrollY: SharedValue + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export function usePagerHeaderContext() { + const scrollY = useContext(PagerHeaderContext) + if (isIOS) { + if (!scrollY) { + throw new Error( + 'usePagerHeaderContext must be used within a HeaderProvider', + ) + } + return {scrollY} + } else { + return null + } +} diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 7b1d8b78f4..528f7fdf2e 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -19,9 +19,10 @@ import Animated, { import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {ScrollProvider} from '#/lib/ScrollContext' -import {isIOS} from 'platform/detection' -import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' +import {isIOS} from '#/platform/detection' +import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager' import {ListMethods} from '../util/List' +import {PagerHeaderProvider} from './PagerHeaderContext' import {TabBar} from './TabBar' export interface PagerWithHeaderChildParams { @@ -41,6 +42,7 @@ export interface PagerWithHeaderProps { initialPage?: number onPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void + allowHeaderOverScroll?: boolean } export const PagerWithHeader = React.forwardRef( function PageWithHeaderImpl( @@ -53,6 +55,7 @@ export const PagerWithHeader = React.forwardRef( initialPage, onPageSelected, onCurrentPageSelected, + allowHeaderOverScroll, }: PagerWithHeaderProps, ref, ) { @@ -80,19 +83,22 @@ export const PagerWithHeader = React.forwardRef( const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - + + + ) }, [ @@ -106,6 +112,7 @@ export const PagerWithHeader = React.forwardRef( onHeaderOnlyLayout, scrollY, testID, + allowHeaderOverScroll, ], ) @@ -216,6 +223,7 @@ let PagerTabBar = ({ onTabBarLayout, onCurrentPageSelected, onSelect, + allowHeaderOverScroll, }: { currentPage: number headerOnlyHeight: number @@ -228,14 +236,20 @@ let PagerTabBar = ({ onTabBarLayout: (e: LayoutChangeEvent) => void onCurrentPageSelected?: (index: number) => void onSelect?: (index: number) => void + allowHeaderOverScroll?: boolean }): React.ReactNode => { - const headerTransform = useAnimatedStyle(() => ({ - transform: [ - { - translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0), - }, - ], - })) + const headerTransform = useAnimatedStyle(() => { + const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1 + return { + transform: [ + { + translateY: allowHeaderOverScroll + ? translateY + : Math.min(translateY, 0), + }, + ], + } + }) const headerRef = React.useRef(null) return ( { + playHaptics('Light') + setTimeout( + () => { + onPressCompose() + }, + isHapticsDisabled ? 0 : 75, + ) + } + return ( - onPressCompose()} + - - + - Write your reply - - + + + Write your reply + + + ) } - -const styles = StyleSheet.create({ - prompt: { - paddingHorizontal: 16, - paddingTop: 10, - paddingBottom: 10, - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: StyleSheet.hairlineWidth, - }, - labelMobile: { - paddingLeft: 12, - }, - labelDesktopWeb: { - paddingLeft: 12, - }, -}) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index d712605d6e..498ab48ee6 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -14,8 +14,11 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useAnalytics} from '#/lib/analytics/analytics' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {logEvent, useGate} from '#/lib/statsig/statsig' +import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {listenPostCreated} from '#/state/events' @@ -30,9 +33,6 @@ import { usePostFeedQuery, } from '#/state/queries/post-feed' import {useSession} from '#/state/session' -import {useAnalytics} from 'lib/analytics/analytics' -import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' -import {useTheme} from 'lib/ThemeContext' import { ProgressGuide, SuggestedFeeds, @@ -167,6 +167,7 @@ let Feed = ({ renderEndOfFeed, testID, headerOffset = 0, + progressViewOffset, desktopFixedHeightOffset, ListHeaderComponent, extraData, @@ -187,6 +188,7 @@ let Feed = ({ renderEndOfFeed?: () => JSX.Element testID?: string headerOffset?: number + progressViewOffset?: number desktopFixedHeightOffset?: number ListHeaderComponent?: () => JSX.Element extraData?: any @@ -548,6 +550,7 @@ let Feed = ({ refreshing={isPTRing} onRefresh={onRefresh} headerOffset={headerOffset} + progressViewOffset={progressViewOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index f9aeae1a86..53b0547d45 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -4,11 +4,11 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated' import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' -import {usePalette} from '#/lib/hooks/usePalette' +import {useDedupe} from '#/lib/hooks/useDedupe' import {useScrollHandlers} from '#/lib/ScrollContext' -import {useDedupe} from 'lib/hooks/useDedupe' -import {addStyle} from 'lib/styles' -import {isIOS} from 'platform/detection' +import {addStyle} from '#/lib/styles' +import {isIOS} from '#/platform/detection' +import {useTheme} from '#/alf' import {FlatList_INTERNAL} from './Views' export type ListMethods = FlatList_INTERNAL @@ -44,12 +44,13 @@ function ListImpl( onItemSeen, headerOffset, style, + progressViewOffset, ...props }: ListProps, ref: React.Ref, ) { const isScrolledDown = useSharedValue(false) - const pal = usePalette('default') + const t = useTheme() const dedupe = useDedupe(400) function handleScrolledDownChange(didScrollDown: boolean) { @@ -120,9 +121,9 @@ function ListImpl( ) } diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 76d9d1503e..2b4376b698 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -327,7 +327,8 @@ let EditableUserAvatar = ({ onSelectNewAvatar(croppedImage) } catch (e: any) { - if (!String(e).includes('Canceled')) { + // Don't log errors for cancelling selection to sentry on ios or android + if (!String(e).toLowerCase().includes('cancel')) { logger.error('Failed to crop banner', {error: e}) } } diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 13f4081fce..0e07a57454 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -202,7 +202,7 @@ const styles = StyleSheet.create({ }, bannerImage: { width: '100%', - height: 150, + height: '100%', }, defaultBanner: { backgroundColor: '#0070ff', diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index b49c49e4ab..fa2b7e3d3f 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,7 +1,7 @@ import React, {useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' import {AppBskyEmbedVideo} from '@atproto/api' -import Hls, {Events, FragChangedData, Fragment} from 'hls.js' +import type * as HlsTypes from 'hls.js' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' @@ -23,6 +23,7 @@ export function VideoEmbedInnerWeb({ const videoRef = useRef(null) const [focused, setFocused] = useState(false) const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) + const [hlsLoading, setHlsLoading] = React.useState(false) const figId = useId() // send error up to error boundary @@ -37,6 +38,7 @@ export function VideoEmbedInnerWeb({ setHasSubtitleTrack, setError, videoRef, + setHlsLoading, }) return ( @@ -77,6 +79,7 @@ export function VideoEmbedInnerWeb({ setActive={setActive} focused={focused} setFocused={setFocused} + hlsLoading={hlsLoading} onScreen={onScreen} fullscreenRef={containerRef} hasSubtitleTrack={hasSubtitleTrack} @@ -99,31 +102,62 @@ export class VideoNotFoundError extends Error { } } +type CachedPromise = Promise & {value: undefined | T} +const promiseForHls = import( + // @ts-ignore + 'hls.js/dist/hls.min' +).then(mod => mod.default) as CachedPromise +promiseForHls.value = undefined +promiseForHls.then(Hls => { + promiseForHls.value = Hls +}) + function useHLS({ focused, playlist, setHasSubtitleTrack, setError, videoRef, + setHlsLoading, }: { focused: boolean playlist: string setHasSubtitleTrack: (v: boolean) => void setError: (v: Error | null) => void videoRef: React.RefObject + setHlsLoading: (v: boolean) => void }) { - const hlsRef = useRef(undefined) - const [lowQualityFragments, setLowQualityFragments] = useState([]) + const [Hls, setHls] = useState( + () => promiseForHls.value, + ) + useEffect(() => { + if (!Hls) { + setHlsLoading(true) + promiseForHls.then(loadedHls => { + setHls(() => loadedHls) + setHlsLoading(false) + }) + } + }, [Hls, setHlsLoading]) + + const hlsRef = useRef(undefined) + const [lowQualityFragments, setLowQualityFragments] = useState< + HlsTypes.Fragment[] + >([]) // purge low quality segments from buffer on next frag change const handleFragChange = useNonReactiveCallback( - (_event: Events.FRAG_CHANGED, {frag}: FragChangedData) => { + ( + _event: HlsTypes.Events.FRAG_CHANGED, + {frag}: HlsTypes.FragChangedData, + ) => { + if (!Hls) return if (!hlsRef.current) return const hls = hlsRef.current if (focused && hls.nextAutoLevel > 0) { // if the current quality level goes above 0, flush the low quality segments - const flushed: Fragment[] = [] + const flushed: HlsTypes.Fragment[] = [] for (const lowQualFrag of lowQualityFragments) { // avoid if close to the current fragment @@ -147,12 +181,15 @@ function useHLS({ useEffect(() => { if (!videoRef.current) return - if (!Hls.isSupported()) throw new HLSUnsupportedError() + if (!Hls) return + if (!Hls.isSupported()) { + throw new HLSUnsupportedError() + } const hls = new Hls({ maxMaxBufferLength: 10, // only load 10s ahead // note: the amount buffered is affected by both maxBufferLength and maxBufferSize - // it will buffer until it it's greater than *both* of those values + // it will buffer until it is greater than *both* of those values // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead }) hlsRef.current = hls @@ -211,7 +248,7 @@ function useHLS({ hls.destroy() abortController.abort() } - }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange]) + }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls]) return hlsRef } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx index 2d1427347d..dd0dafc335 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx @@ -43,6 +43,7 @@ export function Controls({ setFocused, onScreen, fullscreenRef, + hlsLoading, hasSubtitleTrack, }: { videoRef: React.RefObject @@ -53,6 +54,7 @@ export function Controls({ setFocused: (focused: boolean) => void onScreen: boolean fullscreenRef: React.RefObject + hlsLoading: boolean hasSubtitleTrack: boolean }) { const { @@ -80,6 +82,7 @@ export function Controls({ const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) + const showSpinner = hlsLoading || buffering const { state: volumeHovered, onIn: onVolumeHover, @@ -409,11 +412,11 @@ export function Controls({ )} - {(buffering || error) && ( + {(showSpinner || error) && ( - {buffering && } + {showSpinner && } {error && ( An error occurred diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 879632e9ef..b37445fad6 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -37,11 +37,11 @@ import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens' import {ProfileLists} from '#/view/com/lists/ProfileLists' +import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {FAB} from '#/view/com/util/fab/FAB' import {ListRef} from '#/view/com/util/List' import {CenteredView} from '#/view/com/util/Views' -import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' @@ -363,7 +363,8 @@ function ProfileScreenLoaded({ items={sectionTitles} onPageSelected={onPageSelected} onCurrentPageSelected={onCurrentPageSelected} - renderHeader={renderHeader}> + renderHeader={renderHeader} + allowHeaderOverScroll> {showFiltersTab ? ({headerHeight, isFocused, scrollElRef}) => ( { +function SearchLanguageDropdown({ + value, + onChange, +}: { + value: string + onChange(value: string): void +}) { + const t = useThemeNew() + const {contentLanguages} = useLanguagePrefs() + + const items = React.useMemo(() => { + return LANGUAGES.filter(l => Boolean(l.code2)) + .map(l => ({ + label: l.name, + inputLabel: l.name, + value: l.code2, + key: l.code2 + l.code3, + })) + .sort(a => (contentLanguages.includes(a.value) ? -1 : 1)) + }, [contentLanguages]) + + const style = { + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, + color: t.atoms.text.color, + fontSize: a.text_xs.fontSize, + fontFamily: 'inherit', + fontWeight: a.font_bold.fontWeight, + paddingHorizontal: 14, + paddingRight: 32, + paddingVertical: 8, + borderRadius: a.rounded_full.borderRadius, + borderWidth: a.border.borderWidth, + borderColor: t.atoms.border_contrast_low.borderColor, + } + + return ( + ( + + )} + useNativeAndroidPickerStyle={false} + style={{ + iconContainer: { + pointerEvents: 'none', + right: a.px_sm.paddingRight, + top: 0, + bottom: 0, + display: 'flex', + justifyContent: 'center', + }, + inputAndroid: { + ...style, + paddingVertical: 2, + }, + inputIOS: { + ...style, + }, + inputWeb: web({ + ...style, + cursor: 'pointer', + // @ts-ignore web only + '-moz-appearance': 'none', + '-webkit-appearance': 'none', + appearance: 'none', + outline: 0, + borderWidth: 0, + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }), + }} + /> + ) +} + +function useQueryManager({initialQuery}: {initialQuery: string}) { + const {contentLanguages} = useLanguagePrefs() + const {query, params: initialParams} = React.useMemo(() => { + return parseSearchQuery(initialQuery || '') + }, [initialQuery]) + const prevInitialQuery = React.useRef(initialQuery) + const [lang, setLang] = React.useState( + initialParams.lang || contentLanguages[0], + ) + + if (initialQuery !== prevInitialQuery.current) { + // handle new queryParam change (from manual search entry) + prevInitialQuery.current = initialQuery + setLang(initialParams.lang || contentLanguages[0]) + } + + const params = React.useMemo( + () => ({ + // default stuff + ...initialParams, + // managed stuff + lang, + }), + [lang, initialParams], + ) + const handlers = React.useMemo( + () => ({ + setLang, + }), + [setLang], + ) + + return React.useMemo(() => { + return { + query, + queryWithParams: makeSearchQuery(query, params), + params: { + ...params, + ...handlers, + }, + } + }, [query, params, handlers]) +} + +let SearchScreenInner = ({ + query, + queryWithParams, + headerHeight, +}: { + query: string + queryWithParams: string + headerHeight: number +}): React.ReactNode => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() @@ -349,7 +489,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { title: _(msg`Top`), component: ( @@ -359,7 +499,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { title: _(msg`Latest`), component: ( @@ -378,7 +518,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { ), }, ] - }, [_, query, activeTab]) + }, [_, query, queryWithParams, activeTab]) return query ? ( { renderTabBar={props => ( + style={[ + pal.border, + pal.view, + web({ + position: isWeb ? 'sticky' : '', + zIndex: 1, + }), + {top: isWeb ? headerHeight : undefined}, + ]}> section.title)} {...props} /> )} @@ -448,14 +596,14 @@ SearchScreenInner = React.memo(SearchScreenInner) export function SearchScreen( props: NativeStackScreenProps, ) { + const t = useThemeNew() + const {gtMobile} = useBreakpoints() const navigation = useNavigation() const textInput = React.useRef(null) const {_} = useLingui() - const pal = usePalette('default') const {track} = useAnalytics() const setDrawerOpen = useSetDrawerOpen() const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() // Query terms const queryParam = props.route?.params?.q ?? '' @@ -469,6 +617,17 @@ export function SearchScreen( AppBskyActorDefs.ProfileViewBasic[] >([]) + const {params, query, queryWithParams} = useQueryManager({ + initialQuery: queryParam, + }) + const showFiltersButton = Boolean(query && !showAutocomplete) + const [showFilters, setShowFilters] = React.useState(false) + /* + * Arbitrary sizing, so guess and check, used for sticky header alignment and + * sizing. + */ + const headerHeight = 56 + (showFilters ? 40 : 0) + useFocusEffect( useNonReactiveCallback(() => { if (isWeb) { @@ -507,13 +666,6 @@ export function SearchScreen( textInput.current?.focus() }, []) - const onPressCancelSearch = React.useCallback(() => { - scrollToTopWeb() - textInput.current?.blur() - setShowAutocomplete(false) - setSearchText(queryParam) - }, [queryParam]) - const onChangeText = React.useCallback(async (text: string) => { scrollToTopWeb() setSearchText(text) @@ -586,6 +738,13 @@ export function SearchScreen( [updateSearchHistory, navigation], ) + const onPressCancelSearch = React.useCallback(() => { + scrollToTopWeb() + textInput.current?.blur() + setShowAutocomplete(false) + setSearchText(queryParam) + }, [setShowAutocomplete, setSearchText, queryParam]) + const onSubmit = React.useCallback(() => { navigateToItem(searchText) }, [navigateToItem, searchText]) @@ -624,6 +783,7 @@ export function SearchScreen( setSearchText('') navigation.setParams({q: ''}) } + setShowFilters(false) }, [navigation]) useFocusEffect( @@ -663,50 +823,107 @@ export function SearchScreen( [selectedProfiles], ) + const onSearchInputFocus = React.useCallback(() => { + if (isWeb) { + // Prevent a jump on iPad by ensuring that + // the initial focused render has no result list. + requestAnimationFrame(() => { + setShowAutocomplete(true) + }) + } else { + setShowAutocomplete(true) + } + setShowFilters(false) + }, [setShowAutocomplete]) + return ( - {isTabletOrMobile && ( - - - - )} - - {showAutocomplete && ( - - + + {!gtMobile && ( + + )} + + {showFiltersButton && ( + + )} + {showAutocomplete && ( + + )} + + + {showFilters && ( + + + + )} + - + ) @@ -747,7 +968,7 @@ let SearchInputBox = ({ textInput, searchText, showAutocomplete, - setShowAutocomplete, + onFocus, onChangeText, onSubmit, onPressClearQuery, @@ -755,83 +976,62 @@ let SearchInputBox = ({ textInput: React.RefObject searchText: string showAutocomplete: boolean - setShowAutocomplete: (show: boolean) => void + onFocus: () => void onChangeText: (text: string) => void onSubmit: () => void onPressClearQuery: () => void }): React.ReactNode => { - const pal = usePalette('default') const {_} = useLingui() - const theme = useTheme() + const t = useThemeNew() + return ( - { - textInput.current?.focus() - }}> - - { - if (isWeb) { - // Prevent a jump on iPad by ensuring that - // the initial focused render has no result list. - requestAnimationFrame(() => { - setShowAutocomplete(true) - }) - } else { - setShowAutocomplete(true) - } - }} - onChangeText={onChangeText} - onSubmitEditing={onSubmit} - autoFocus={false} - accessibilityRole="search" - accessibilityLabel={_(msg`Search`)} - accessibilityHint="" - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - /> + + + + + + {showAutocomplete && searchText.length > 0 && ( - - - + + + )} - + ) } SearchInputBox = React.memo(SearchInputBox) @@ -1029,21 +1229,7 @@ function scrollToTopWeb() { } } -const HEADER_HEIGHT = 46 - const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - paddingLeft: 13, - paddingVertical: 4, - height: HEADER_HEIGHT, - // @ts-ignore web only - position: isWeb ? 'sticky' : '', - top: 0, - zIndex: 1, - }, headerMenuBtn: { width: 30, height: 30, @@ -1075,12 +1261,6 @@ const styles = StyleSheet.create({ zIndex: -1, elevation: -1, // For Android }, - tabBarContainer: { - // @ts-ignore web only - position: isWeb ? 'sticky' : '', - top: isWeb ? HEADER_HEIGHT : 0, - zIndex: 1, - }, searchHistoryContainer: { width: '100%', paddingHorizontal: 12, diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 737ca2d28a..73bfaa83ef 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -94,10 +94,14 @@ function SettingsAccountCard({ /> - + {profile?.displayName || account.handle} - + {account.handle} diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index fc414d31f3..8ec118ae3e 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -32,7 +32,7 @@ export function Forms() { label="Text field" /> - + , + ComponentProps, | 'accessible' | 'accessibilityRole' | 'accessibilityHint' @@ -350,7 +352,7 @@ function Btn({ accessibilityLabel, }: BtnProps) { return ( - + accessibilityHint={accessibilityHint} + targetScale={0.8} + contentContainerStyle={[a.flex_1]}> {icon} {notificationCount ? ( - + {notificationCount} ) : undefined} - + ) } diff --git a/yarn.lock b/yarn.lock index 17fe862372..db8a707a40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12165,6 +12165,11 @@ expo-asset@~10.0.6: invariant "^2.2.4" md5-file "^3.2.3" +expo-blur@^13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-13.0.2.tgz#c2d179b19b13830db1d8b90c51373235f462e958" + integrity sha512-t2p7BChO3Reykued++QJRMZ/og6J3aXtSQ+bU31YcBeXhZLkHwjWEhiPKPnJka7J2/yTs4+jOCNDY0kCZmcE3w== + expo-build-properties@^0.12.1: version "0.12.1" resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad"